pi-mega-compact 0.11.3 → 0.11.4

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.
@@ -46,6 +46,26 @@ function eventTypes(stateDir) {
46
46
  } })
47
47
  .filter((t) => typeof t === "string");
48
48
  }
49
+ /** R11: read events.log and return full JSON payloads for a given type. */
50
+ function eventPayloads(stateDir, type) {
51
+ const { readFileSync: rf, existsSync: ex } = require("node:fs");
52
+ const { join: j } = require("node:path");
53
+ const logPath = j(stateDir, "events.log");
54
+ if (!ex(logPath))
55
+ return [];
56
+ const content = rf(logPath, "utf-8").trim();
57
+ if (content.length === 0)
58
+ return [];
59
+ return content
60
+ .split("\n")
61
+ .map((line) => { try {
62
+ return JSON.parse(line);
63
+ }
64
+ catch {
65
+ return undefined;
66
+ } })
67
+ .filter((p) => typeof p === "object" && p !== null && p.type === type);
68
+ }
49
69
  /** Build a mock pi + ctx and load the extension into them. */
50
70
  function harness(opts = {}) {
51
71
  const stateDir = join(baseTmp, `run-${counter++}`);
@@ -109,7 +129,7 @@ function harness(opts = {}) {
109
129
  ctx: makeCtx, session,
110
130
  };
111
131
  }
112
- const { classifyError: classifyErrorFn } = require("./mega-events.js");
132
+ const { classifyError: classifyErrorFn, classifyErrorDetailed: classifyErrorDetailedFn, extractErrorSignature: extractErrorSignatureFn } = require("./mega-events.js");
113
133
  /** S38 helper: fire a turn_end with a given stopReason + optional text, using a
114
134
  * low-pressure ctx so the durable-trim branch (ctx.compact) does NOT fire. */
115
135
  async function s38TurnEnd(h, stopReason, text) {
@@ -270,15 +290,26 @@ test("S38: R1 burst of immediate transient errors fires 1 nudge (dedup), rest su
270
290
  // the queued nudge (deliverAs:'followUp') has not been consumed by a new
271
291
  // agent turn. errorRetryCount still advances for each error turn, so the
272
292
  // per-burst max + circuit breaker still bound the burst.
273
- const h = harness();
274
- for (let i = 0; i < 5; i++)
275
- await s38TurnEnd(h, "error", `internal server error ${i}`);
276
- assert.equal(h.sendUserMessages.length, 1, "R1 dedup: 1 nudge in burst (rest suppressed)");
277
- // The 6th turn reaches count=6 > max=5 → exhausted (count advances even for dedup'd turns).
278
- await s38TurnEnd(h, "error", "internal server error 5");
279
- assert.equal(h.sendUserMessages.length, 1, "exhausted: still 1 nudge (no 6th)");
280
- assert.ok(eventTypes(h.stateDir).includes("error_retry_exhausted"), "exhausted event logged on max+1");
281
- assert.ok(eventTypes(h.stateDir).includes("error_retry_dedup_skip"), "dedup_skip events logged for suppressed turns");
293
+ // R10: disable outage advisory so this test isolates nudge dedup only.
294
+ const prevOutage = process.env.MEGACOMPACT_PROVIDER_OUTAGE_THRESHOLD;
295
+ process.env.MEGACOMPACT_PROVIDER_OUTAGE_THRESHOLD = "0";
296
+ try {
297
+ const h = harness();
298
+ for (let i = 0; i < 5; i++)
299
+ await s38TurnEnd(h, "error", `internal server error ${i}`);
300
+ assert.equal(h.sendUserMessages.length, 1, "R1 dedup: 1 nudge in burst (rest suppressed)");
301
+ // The 6th turn reaches count=6 > max=5 → exhausted (count advances even for dedup'd turns).
302
+ await s38TurnEnd(h, "error", "internal server error 5");
303
+ assert.equal(h.sendUserMessages.length, 1, "exhausted: still 1 nudge (no 6th)");
304
+ assert.ok(eventTypes(h.stateDir).includes("error_retry_exhausted"), "exhausted event logged on max+1");
305
+ assert.ok(eventTypes(h.stateDir).includes("error_retry_dedup_skip"), "dedup_skip events logged for suppressed turns");
306
+ }
307
+ finally {
308
+ if (prevOutage === undefined)
309
+ delete process.env.MEGACOMPACT_PROVIDER_OUTAGE_THRESHOLD;
310
+ else
311
+ process.env.MEGACOMPACT_PROVIDER_OUTAGE_THRESHOLD = prevOutage;
312
+ }
282
313
  });
283
314
  test("S38: retry fires 1x for permanent errors then stops", async () => {
284
315
  const h = harness();
@@ -402,10 +433,13 @@ test("S38.5: strict (default) defers ctx.compact() via setTimeout re-check", asy
402
433
  }
403
434
  });
404
435
  // ---- R3 classifier unit tests: poisoned-context signals ----
405
- test("R3 classifier: 0-token generic error (usage present, 0 tokens) → poisoned-context", () => {
406
- // The 2026-07-28 incident: stopReason 'error' + usage 0 tokens. The turn
407
- // never reached the model; retrying re-submits the same poisoned context.
408
- assert.equal(classifyErrorFn({ stopReason: "error", usage: { inputTokens: 0, outputTokens: 0 } }), "poisoned-context");
436
+ test("R9 classifier: bare 0-token generic error (usage present, 0 tokens) → transient (corroboration required)", () => {
437
+ // R9: The 2026-07-30 incidents proved that 0-token deterministic rejection
438
+ // when a router fronts the provider (the request never reached any model).
439
+ // Signal 3 now returns 'transient'; the repeat detector in agent-handlers.ts
440
+ // becomes the corroboration mechanism. A bare 0-token error that repeats
441
+ // ≥ poisonedContextRepeatThreshold (default 3) upgrades to poisoned.
442
+ assert.equal(classifyErrorFn({ stopReason: "error", usage: { inputTokens: 0, outputTokens: 0 } }), "transient");
409
443
  // Bare stopReason 'error' with NO usage field stays transient (unknown tokens
410
444
  // — conservative; preserves the pre-R3 mid-response/partial-content behavior).
411
445
  assert.equal(classifyErrorFn({ stopReason: "error" }), "transient");
@@ -453,8 +487,11 @@ test("R6(a): 10 consecutive identical 0-token transient failures produce at most
453
487
  // exercises the SESSION CAP, not the repeat signal.
454
488
  const prevBackoff = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
455
489
  const prevRepeat = process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD;
490
+ const prevOutage = process.env.MEGACOMPACT_PROVIDER_OUTAGE_THRESHOLD;
456
491
  process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
457
492
  process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD = "999";
493
+ // R10: disable the outage advisory so this test isolates the session cap only.
494
+ process.env.MEGACOMPACT_PROVIDER_OUTAGE_THRESHOLD = "0";
458
495
  try {
459
496
  const h = harness();
460
497
  for (let i = 0; i < 10; i++) {
@@ -475,6 +512,10 @@ test("R6(a): 10 consecutive identical 0-token transient failures produce at most
475
512
  delete process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD;
476
513
  else
477
514
  process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD = prevRepeat;
515
+ if (prevOutage === undefined)
516
+ delete process.env.MEGACOMPACT_PROVIDER_OUTAGE_THRESHOLD;
517
+ else
518
+ process.env.MEGACOMPACT_PROVIDER_OUTAGE_THRESHOLD = prevOutage;
478
519
  }
479
520
  });
480
521
  test("R6(b): poisoned-context fires zero retry nudges and exactly one advise message", async () => {
@@ -507,9 +548,12 @@ test("R6(c): transient burst retries with backoff gating — second immediate nu
507
548
  const prevBackoff = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
508
549
  const prevSession = process.env.MEGACOMPACT_ERROR_RETRY_SESSION_MAX;
509
550
  const prevRepeat = process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD;
551
+ const prevOutage = process.env.MEGACOMPACT_PROVIDER_OUTAGE_THRESHOLD;
510
552
  process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
511
553
  process.env.MEGACOMPACT_ERROR_RETRY_SESSION_MAX = "999"; // don't let session cap bind
512
554
  process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD = "999"; // don't let repeat upgrade bind
555
+ // R10: disable the outage advisory so this test isolates backoff gating only.
556
+ process.env.MEGACOMPACT_PROVIDER_OUTAGE_THRESHOLD = "0";
513
557
  try {
514
558
  const h = harness();
515
559
  // Turn 1: transient → nudge 1 fires (pending=true, backoff=1ms).
@@ -538,6 +582,10 @@ test("R6(c): transient burst retries with backoff gating — second immediate nu
538
582
  delete process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD;
539
583
  else
540
584
  process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD = prevRepeat;
585
+ if (prevOutage === undefined)
586
+ delete process.env.MEGACOMPACT_PROVIDER_OUTAGE_THRESHOLD;
587
+ else
588
+ process.env.MEGACOMPACT_PROVIDER_OUTAGE_THRESHOLD = prevOutage;
541
589
  }
542
590
  });
543
591
  test("R6(d): user abort (stopReason aborted) never nudges, even across repeated aborts", async () => {
@@ -610,6 +658,14 @@ test("R3: repeated identical transient error text upgrades to poisoned-context a
610
658
  process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = prevBackoff;
611
659
  }
612
660
  });
661
+ test("R9: extractErrorSignature fallback — bare 0-token error -> 'bare-0-token-error'", () => {
662
+ // The repeat detector needs a non-empty signature to track bare 0-token errors.
663
+ assert.equal(extractErrorSignatureFn({ stopReason: "error", usage: { inputTokens: 0, outputTokens: 0 } }), "bare-0-token-error");
664
+ // Without usage, stopReason alone does NOT get the fallback (mid-response deaths stay out of repeat tracking).
665
+ assert.equal(extractErrorSignatureFn({ stopReason: "error" }), "");
666
+ // Normal text content is returned as-is.
667
+ assert.equal(extractErrorSignatureFn({ stopReason: "error", content: "x" }), "x");
668
+ });
613
669
  // ---- R7 regression tests: network/throughput errors must NEVER upgrade to ----
614
670
  // ---- poisoned-context (2026-07-30 false-alarm incident) ----
615
671
  /** R7 helper: fire the same transient error text `count` times with turn_start
@@ -799,6 +855,513 @@ test("R7 classifier: bare network phrasings → transient", () => {
799
855
  test("R7 classifier: 429/rate-limit → transient (control)", () => {
800
856
  assert.equal(classifyErrorFn("429 Too Many Requests: rate limit exceeded"), "transient");
801
857
  });
858
+ // ---- R8 regression tests: router-wrapped infra errors (2026-07-30 incident #2) ----
859
+ // pi's console "Error: 500:" prefix is NOT part of the delivered message body,
860
+ // so router phrasings ("All targets failed", "No healthy target selected",
861
+ // "Too many concurrent requests") matched NO marker and the 0-token signal
862
+ // poisoned the session on the FIRST turn.
863
+ /** The exact error bodies from the 2026-07-30 incident (GLM router flapping). */
864
+ const R8_NO_HEALTHY_TARGET = '{"message":"No healthy target selected for alias \'hf:zai-org/GLM-4.7\'","type":"api_error"}';
865
+ const R8_SOCKET_CLOSED = "All targets failed: modal/zai-org/GLM-5.1-FP8. Last error: The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()"; // guardrails-allow PREVENT-PI-004: verbatim 2026-07-30 incident error text (string fixture, not a network call)
866
+ const R8_TOO_MANY_CONCURRENT = 'All targets failed: modal/zai-org/GLM-5.1-FP8. Last error: {"error": "Too many concurrent requests for this model"}';
867
+ test("R8 classifier: router phrasings → transient, even at 0 tokens", () => {
868
+ for (const text of [R8_NO_HEALTHY_TARGET, R8_SOCKET_CLOSED, R8_TOO_MANY_CONCURRENT]) {
869
+ assert.equal(classifyErrorFn({ stopReason: "error", content: text, usage: { inputTokens: 0, outputTokens: 0 } }), "transient", `0-token router error must be transient: ${text.slice(0, 60)}`);
870
+ }
871
+ });
872
+ test("R8 classifier: structured status field wins over phrasing", () => {
873
+ // 5xx → transient even with zero recognizable text.
874
+ assert.equal(classifyErrorFn({ stopReason: "error", error: { status: 502, message: "???" }, usage: { inputTokens: 0, outputTokens: 0 } }), "transient");
875
+ // 429 structured → transient.
876
+ assert.equal(classifyErrorFn({ stopReason: "error", error: { statusCode: 429, message: "???" }, usage: { inputTokens: 0, outputTokens: 0 } }), "transient");
877
+ // 401 → permanent (not transient, not poisoned).
878
+ assert.equal(classifyErrorFn({ stopReason: "error", error: { status: 401, message: "???" } }), "permanent");
879
+ // 400 with deterministic-rejection text → still poisoned (text rules 4xx).
880
+ assert.equal(classifyErrorFn({ stopReason: "error", error: { status: 400, message: "orphaned tool result: tooluse ids mismatch" }, usage: { inputTokens: 0, outputTokens: 0 } }), "poisoned-context");
881
+ });
882
+ test("R8(a): 0-token 'No healthy target' turn is NOT poisoned on first occurrence", async () => {
883
+ const prevAuto = process.env.MEGACOMPACT_AUTO;
884
+ process.env.MEGACOMPACT_AUTO = "false";
885
+ try {
886
+ const h = harness();
887
+ await s38TurnEndUsage(h, "error", R8_NO_HEALTHY_TARGET, 0);
888
+ assert.ok(!eventTypes(h.stateDir).includes("poisoned_context"), "no poisoned_context on first turn");
889
+ assert.ok(!h.sendUserMessages.some((m) => m.includes("/clear")), "no /clear advise");
890
+ }
891
+ finally {
892
+ if (prevAuto === undefined)
893
+ delete process.env.MEGACOMPACT_AUTO;
894
+ else
895
+ process.env.MEGACOMPACT_AUTO = prevAuto;
896
+ }
897
+ });
898
+ test("R8(b): repeated 'No healthy target' x3 stays transient", async () => {
899
+ const prevAuto = process.env.MEGACOMPACT_AUTO;
900
+ const prevBackoff = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
901
+ process.env.MEGACOMPACT_AUTO = "false";
902
+ process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
903
+ try {
904
+ const h = harness();
905
+ await r7RepeatTurns(h, R8_NO_HEALTHY_TARGET, 3);
906
+ assertStaysTransient(h, "no-healthy-target x3");
907
+ }
908
+ finally {
909
+ if (prevAuto === undefined)
910
+ delete process.env.MEGACOMPACT_AUTO;
911
+ else
912
+ process.env.MEGACOMPACT_AUTO = prevAuto;
913
+ if (prevBackoff === undefined)
914
+ delete process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
915
+ else
916
+ process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = prevBackoff;
917
+ }
918
+ });
919
+ test("R8(c): repeated 'All targets failed / socket closed' x3 stays transient", async () => {
920
+ const prevAuto = process.env.MEGACOMPACT_AUTO;
921
+ const prevBackoff = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
922
+ process.env.MEGACOMPACT_AUTO = "false";
923
+ process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
924
+ try {
925
+ const h = harness();
926
+ await r7RepeatTurns(h, R8_SOCKET_CLOSED, 3);
927
+ assertStaysTransient(h, "all-targets-failed x3");
928
+ }
929
+ finally {
930
+ if (prevAuto === undefined)
931
+ delete process.env.MEGACOMPACT_AUTO;
932
+ else
933
+ process.env.MEGACOMPACT_AUTO = prevAuto;
934
+ if (prevBackoff === undefined)
935
+ delete process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
936
+ else
937
+ process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = prevBackoff;
938
+ }
939
+ });
940
+ test("R9 handler: bare 0-token error turn x1 is transient (no poisoned, no advise)", async () => {
941
+ const h = harness();
942
+ const origBackoff = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
943
+ process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
944
+ process.env.MEGACOMPACT_AUTO = "false";
945
+ try {
946
+ // Single bare 0-token error turn
947
+ await s38TurnEndUsage(h, "error", undefined, 0);
948
+ await h.fire("turn_start", { type: "turn_start", turnIndex: 2 }, h.ctx());
949
+ await new Promise((r) => setTimeout(r, 3));
950
+ // No poisoned_context event should be logged
951
+ assert.ok(!eventTypes(h.stateDir).includes("poisoned_context"), "bare 0-token single turn must not log poisoned_context");
952
+ // No /clear advise message
953
+ assert.equal(h.sendUserMessages.filter((m) => m.includes("/clear")).length, 0, "bare 0-token single turn must not /clear");
954
+ // An error_retry event should be emitted
955
+ assert.ok(eventTypes(h.stateDir).includes("error_retry"), "bare 0-token single turn must trigger error_retry");
956
+ }
957
+ finally {
958
+ if (origBackoff === undefined)
959
+ delete process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
960
+ else
961
+ process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = origBackoff;
962
+ delete process.env.MEGACOMPACT_AUTO;
963
+ }
964
+ });
965
+ test("R9 handler: bare 0-token error x3 upgrades to poisoned at threshold (corroborated)", async () => {
966
+ const h = harness();
967
+ const origBackoff = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
968
+ process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
969
+ process.env.MEGACOMPACT_AUTO = "false";
970
+ try {
971
+ // Three consecutive bare 0-token error turns
972
+ for (let i = 0; i < 3; i++) {
973
+ await s38TurnEndUsage(h, "error", undefined, 0);
974
+ await h.fire("turn_start", { type: "turn_start", turnIndex: i + 2 }, h.ctx());
975
+ await new Promise((r) => setTimeout(r, 3));
976
+ }
977
+ // poisoned_context event should eventually be logged
978
+ assert.ok(eventTypes(h.stateDir).includes("poisoned_context"), "bare 0-token x3 must log poisoned_context");
979
+ // Exactly one /clear advise total
980
+ assert.equal(h.sendUserMessages.filter((m) => m.includes("/clear")).length, 1, "bare 0-token x3 must produce exactly one /clear advise");
981
+ }
982
+ finally {
983
+ if (origBackoff === undefined)
984
+ delete process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
985
+ else
986
+ process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = origBackoff;
987
+ delete process.env.MEGACOMPACT_AUTO;
988
+ }
989
+ });
990
+ // ---- R10 tests: provider outage advisory (2026-07-30) ----
991
+ test("R10: 3 consecutive transient failures fire one provider-outage advisory (no /clear)", async () => {
992
+ const origBackoff = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
993
+ const origSession = process.env.MEGACOMPACT_ERROR_RETRY_SESSION_MAX;
994
+ const origRepeat = process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD;
995
+ process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
996
+ process.env.MEGACOMPACT_ERROR_RETRY_SESSION_MAX = "999";
997
+ process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD = "999";
998
+ try {
999
+ const h = harness();
1000
+ for (let i = 0; i < 3; i++) {
1001
+ await s38TurnEnd(h, "error", "socket hang up");
1002
+ await h.fire("turn_start", { type: "turn_start", turnIndex: i + 2 }, h.ctx());
1003
+ await new Promise((r) => setTimeout(r, 3));
1004
+ }
1005
+ const providerMessages = h.sendUserMessages.filter((m) => m.includes("provider is having issues"));
1006
+ assert.equal(providerMessages.length, 1, "exactly one provider-outage advisory");
1007
+ const clearMessages = h.sendUserMessages.filter((m) => m.includes("/clear"));
1008
+ assert.equal(clearMessages.length, 0, "no /clear in outage advisory");
1009
+ assert.ok(eventTypes(h.stateDir).includes("error_retry"), "error_retry events present");
1010
+ }
1011
+ finally {
1012
+ if (origBackoff === undefined)
1013
+ delete process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
1014
+ else
1015
+ process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = origBackoff;
1016
+ if (origSession === undefined)
1017
+ delete process.env.MEGACOMPACT_ERROR_RETRY_SESSION_MAX;
1018
+ else
1019
+ process.env.MEGACOMPACT_ERROR_RETRY_SESSION_MAX = origSession;
1020
+ if (origRepeat === undefined)
1021
+ delete process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD;
1022
+ else
1023
+ process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD = origRepeat;
1024
+ }
1025
+ });
1026
+ test("R10: advisory fires once per outage episode", async () => {
1027
+ const origBackoff = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
1028
+ const origSession = process.env.MEGACOMPACT_ERROR_RETRY_SESSION_MAX;
1029
+ const origRepeat = process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD;
1030
+ process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
1031
+ process.env.MEGACOMPACT_ERROR_RETRY_SESSION_MAX = "999";
1032
+ process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD = "999";
1033
+ try {
1034
+ const h = harness();
1035
+ // Episode 1: 5 consecutive transient failures → 1 advisory at consecutiveErrors=3.
1036
+ for (let i = 0; i < 5; i++) {
1037
+ await s38TurnEnd(h, "error", "socket hang up");
1038
+ await h.fire("turn_start", { type: "turn_start", turnIndex: i + 2 }, h.ctx());
1039
+ await new Promise((r) => setTimeout(r, 3));
1040
+ }
1041
+ assert.equal(h.sendUserMessages.filter((m) => m.includes("provider is having issues")).length, 1, "episode 1: 1 advisory after 5 transient turns");
1042
+ // Success turn: resets consecutiveErrors + providerOutageAdvised.
1043
+ await s38TurnEnd(h, "stop");
1044
+ await h.fire("turn_start", { type: "turn_start", turnIndex: 8 }, h.ctx());
1045
+ await new Promise((r) => setTimeout(r, 3));
1046
+ // Episode 2: 3 more transient failures → second advisory.
1047
+ for (let i = 0; i < 3; i++) {
1048
+ await s38TurnEnd(h, "error", "socket hang up");
1049
+ await h.fire("turn_start", { type: "turn_start", turnIndex: 9 + i }, h.ctx());
1050
+ await new Promise((r) => setTimeout(r, 3));
1051
+ }
1052
+ assert.equal(h.sendUserMessages.filter((m) => m.includes("provider is having issues")).length, 2, "episode 2: exactly 2 advisories total");
1053
+ }
1054
+ finally {
1055
+ if (origBackoff === undefined)
1056
+ delete process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
1057
+ else
1058
+ process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = origBackoff;
1059
+ if (origSession === undefined)
1060
+ delete process.env.MEGACOMPACT_ERROR_RETRY_SESSION_MAX;
1061
+ else
1062
+ process.env.MEGACOMPACT_ERROR_RETRY_SESSION_MAX = origSession;
1063
+ if (origRepeat === undefined)
1064
+ delete process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD;
1065
+ else
1066
+ process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD = origRepeat;
1067
+ }
1068
+ });
1069
+ test("R10: MEGACOMPACT_PROVIDER_OUTAGE_THRESHOLD=0 disables the advisory", async () => {
1070
+ const origBackoff = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
1071
+ const origSession = process.env.MEGACOMPACT_ERROR_RETRY_SESSION_MAX;
1072
+ const origRepeat = process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD;
1073
+ const origThreshold = process.env.MEGACOMPACT_PROVIDER_OUTAGE_THRESHOLD;
1074
+ process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
1075
+ process.env.MEGACOMPACT_ERROR_RETRY_SESSION_MAX = "999";
1076
+ process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD = "999";
1077
+ process.env.MEGACOMPACT_PROVIDER_OUTAGE_THRESHOLD = "0";
1078
+ try {
1079
+ const h = harness();
1080
+ for (let i = 0; i < 5; i++) {
1081
+ await s38TurnEnd(h, "error", "socket hang up");
1082
+ await h.fire("turn_start", { type: "turn_start", turnIndex: i + 2 }, h.ctx());
1083
+ await new Promise((r) => setTimeout(r, 3));
1084
+ }
1085
+ const providerMessages = h.sendUserMessages.filter((m) => m.includes("provider is having issues"));
1086
+ assert.equal(providerMessages.length, 0, "threshold=0: zero advisory messages");
1087
+ }
1088
+ finally {
1089
+ if (origBackoff === undefined)
1090
+ delete process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
1091
+ else
1092
+ process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = origBackoff;
1093
+ if (origSession === undefined)
1094
+ delete process.env.MEGACOMPACT_ERROR_RETRY_SESSION_MAX;
1095
+ else
1096
+ process.env.MEGACOMPACT_ERROR_RETRY_SESSION_MAX = origSession;
1097
+ if (origRepeat === undefined)
1098
+ delete process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD;
1099
+ else
1100
+ process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD = origRepeat;
1101
+ if (origThreshold === undefined)
1102
+ delete process.env.MEGACOMPACT_PROVIDER_OUTAGE_THRESHOLD;
1103
+ else
1104
+ process.env.MEGACOMPACT_PROVIDER_OUTAGE_THRESHOLD = origThreshold;
1105
+ }
1106
+ });
1107
+ test("R10: poisoned-context path does NOT fire the outage advisory", async () => {
1108
+ // Single "Request failed — please retry." 0-token turn: poisoned on turn 1
1109
+ // (auto=false to skip the compact attempt).
1110
+ const origBackoff = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
1111
+ const origAuto = process.env.MEGACOMPACT_AUTO;
1112
+ process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
1113
+ process.env.MEGACOMPACT_AUTO = "false";
1114
+ try {
1115
+ const h = harness();
1116
+ await s38TurnEndUsage(h, "error", "Request failed — please retry.", 0);
1117
+ // /clear advise should fire.
1118
+ assert.ok(h.sendUserMessages.some((m) => m.includes("/clear")), "poisoned: /clear advise fires");
1119
+ // No provider-outage advisory.
1120
+ assert.equal(h.sendUserMessages.filter((m) => m.includes("provider is having issues")).length, 0, "poisoned path: no outage advisory");
1121
+ }
1122
+ finally {
1123
+ if (origBackoff === undefined)
1124
+ delete process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
1125
+ else
1126
+ process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = origBackoff;
1127
+ if (origAuto === undefined)
1128
+ delete process.env.MEGACOMPACT_AUTO;
1129
+ else
1130
+ process.env.MEGACOMPACT_AUTO = origAuto;
1131
+ }
1132
+ });
1133
+ // ── R11: diagnostic signal + rawText logging ──────────────────────────────
1134
+ test("R11 classifier: classifyErrorDetailed signal tags", () => {
1135
+ // length → signal 'length-guard', category null
1136
+ const len = classifyErrorDetailedFn({ stopReason: "length" });
1137
+ assert.equal(len.signal, "length-guard");
1138
+ assert.equal(len.category, null);
1139
+ // aborted → 'cancelled'
1140
+ const abort = classifyErrorDetailedFn({ stopReason: "aborted" });
1141
+ assert.equal(abort.signal, "cancelled");
1142
+ assert.equal(abort.category, "cancelled");
1143
+ // stop → 'success'
1144
+ assert.equal(classifyErrorDetailedFn({ stopReason: "stop" }).signal, "success");
1145
+ // compaction-noop
1146
+ const compact = classifyErrorDetailedFn("Error: Already compacted");
1147
+ assert.equal(compact.signal, "compaction-noop");
1148
+ assert.equal(compact.category, "compaction-noop");
1149
+ // context-overflow
1150
+ const overflow = classifyErrorDetailedFn("too long for this model");
1151
+ assert.equal(overflow.signal, "context-overflow");
1152
+ assert.equal(overflow.category, "context-overflow");
1153
+ // transient-marker (text-matched)
1154
+ const tm = classifyErrorDetailedFn("socket hang up");
1155
+ assert.equal(tm.signal, "transient-marker");
1156
+ assert.equal(tm.category, "transient");
1157
+ // transient-status (HTTP status-matched) — use outer-level status + non-5xx-matching error text
1158
+ // so extractHttpStatus finds it but the text doesn't contain "5xx" digits
1159
+ const ts = classifyErrorDetailedFn({ stopReason: "error", error: { type: "upstream_error", message: "request aborted" }, status: 502 });
1160
+ assert.equal(ts.signal, "transient-status");
1161
+ assert.equal(ts.category, "transient");
1162
+ assert.equal(ts.httpStatus, 502);
1163
+ // permanent-status
1164
+ const ps = classifyErrorDetailedFn({ stopReason: "error", error: { type: "auth_error", message: "session expired" }, status: 401 });
1165
+ assert.equal(ps.signal, "permanent-status");
1166
+ assert.equal(ps.category, "permanent");
1167
+ assert.equal(ps.httpStatus, 401);
1168
+ // poisoned-invalid-request (needs stopReason so sr is non-empty)
1169
+ const pir = classifyErrorDetailedFn({ stopReason: "error", error: { type: "invalid_request_error", message: "bad request" } });
1170
+ assert.equal(pir.signal, "poisoned-invalid-request");
1171
+ assert.equal(pir.category, "poisoned-context");
1172
+ // poisoned-request-failed (from R9/R3)
1173
+ const prf = classifyErrorDetailedFn("Request failed — please retry.");
1174
+ assert.equal(prf.signal, "poisoned-request-failed");
1175
+ assert.equal(prf.category, "poisoned-context");
1176
+ // bare-0-token (R9) — usage present with 0 tokens, stopReason error
1177
+ const b0 = classifyErrorDetailedFn({ stopReason: "error", usage: { inputTokens: 0, outputTokens: 0 } });
1178
+ assert.equal(b0.signal, "bare-0-token");
1179
+ assert.equal(b0.category, "transient");
1180
+ // generic-error
1181
+ const ge = classifyErrorDetailedFn({ stopReason: "error", content: "something odd" });
1182
+ assert.equal(ge.signal, "generic-error");
1183
+ assert.equal(ge.category, "transient");
1184
+ // permanent-auth
1185
+ const pa = classifyErrorDetailedFn("unauthorized: invalid api key");
1186
+ assert.equal(pa.signal, "permanent-auth");
1187
+ assert.equal(pa.category, "permanent");
1188
+ // unknown (plain string, no markers)
1189
+ const unk = classifyErrorDetailedFn("some random text with no pattern");
1190
+ assert.equal(unk.signal, "unknown");
1191
+ assert.equal(unk.category, null);
1192
+ });
1193
+ test("R11: poisoned_context event carries signal + rawText", async () => {
1194
+ const prevAuto = process.env.MEGACOMPACT_AUTO;
1195
+ process.env.MEGACOMPACT_AUTO = "false";
1196
+ try {
1197
+ const h = harness();
1198
+ await s38TurnEndUsage(h, "error", "Request failed — please retry.", 0);
1199
+ const payloads = eventPayloads(h.stateDir, "poisoned_context");
1200
+ assert.ok(payloads.length >= 1, "expected a poisoned_context event");
1201
+ const last = payloads[payloads.length - 1];
1202
+ assert.equal(last.signal, "poisoned-request-failed", "signal field present and correct");
1203
+ assert.ok(typeof last.rawText === "string" && last.rawText.toLowerCase().includes("request failed"), "rawText contains error text");
1204
+ }
1205
+ finally {
1206
+ if (prevAuto === undefined)
1207
+ delete process.env.MEGACOMPACT_AUTO;
1208
+ else
1209
+ process.env.MEGACOMPACT_AUTO = prevAuto;
1210
+ }
1211
+ });
1212
+ test("R11: provider outage advisory payload carries signal + rawText", async () => {
1213
+ const origBackoff = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
1214
+ const origSession = process.env.MEGACOMPACT_ERROR_RETRY_SESSION_MAX;
1215
+ const origRepeat = process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD;
1216
+ process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
1217
+ process.env.MEGACOMPACT_ERROR_RETRY_SESSION_MAX = "999";
1218
+ process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD = "999";
1219
+ try {
1220
+ const h = harness();
1221
+ for (let i = 0; i < 3; i++) {
1222
+ await s38TurnEnd(h, "error", "socket hang up");
1223
+ await h.fire("turn_start", { type: "turn_start", turnIndex: i + 2 }, h.ctx());
1224
+ await new Promise((r) => setTimeout(r, 3));
1225
+ }
1226
+ const payloads = eventPayloads(h.stateDir, "provider_outage_advised");
1227
+ assert.ok(payloads.length >= 1, "expected provider_outage_advised event");
1228
+ const last = payloads[payloads.length - 1];
1229
+ assert.equal(last.signal, "transient-marker", "outage event carries signal");
1230
+ assert.ok(typeof last.rawText === "string" && last.rawText.includes("socket hang up"), "outage event carries rawText");
1231
+ }
1232
+ finally {
1233
+ if (origBackoff === undefined)
1234
+ delete process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
1235
+ else
1236
+ process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = origBackoff;
1237
+ if (origSession === undefined)
1238
+ delete process.env.MEGACOMPACT_ERROR_RETRY_SESSION_MAX;
1239
+ else
1240
+ process.env.MEGACOMPACT_ERROR_RETRY_SESSION_MAX = origSession;
1241
+ if (origRepeat === undefined)
1242
+ delete process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD;
1243
+ else
1244
+ process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD = origRepeat;
1245
+ }
1246
+ });
1247
+ test("R11: repeat-upgrade-declined signal is transient-marker for retryable marker", () => {
1248
+ // The repeat-upgrade-declined log fires when isKnownRetryable(signature) is true
1249
+ // but the count hasn't reached the threshold yet (decline path). Since the
1250
+ // test harness doesn't expose internal logger output, we unit-test the signal
1251
+ // tag that the handler would log.
1252
+ const detail = classifyErrorDetailedFn("Request timed out or failed. Try again");
1253
+ assert.equal(detail.signal, "transient-marker", "repeat-guard text gets transient-marker signal");
1254
+ assert.equal(detail.category, "transient");
1255
+ });
1256
+ // ── R12: normalize volatile tokens in extractErrorSignature ──────────────
1257
+ test("R12: extractErrorSignature normalizes volatile tokens", () => {
1258
+ // Model/provider paths → <model>
1259
+ const sigA = extractErrorSignatureFn("All targets failed: modal/zai-org/GLM-5.1-FP8. Last error: boom");
1260
+ const sigB = extractErrorSignatureFn("All targets failed: hf/other-org/GLM-4.7. Last error: boom");
1261
+ assert.equal(sigA, sigB, "different model paths must normalize to the same signature");
1262
+ assert.ok(sigA.includes("<model>"), "normalized sig should contain <model>");
1263
+ assert.ok(!sigA.includes("modal"), "original model alias must be replaced");
1264
+ assert.ok(!sigA.includes("hf/other-org"), "original model alias must be replaced");
1265
+ // IP:port → <ip>
1266
+ const ipA = extractErrorSignatureFn("connect ETIMEDOUT 10.0.0.1:443");
1267
+ const ipB = extractErrorSignatureFn("connect ETIMEDOUT 192.168.0.2:8443");
1268
+ assert.equal(ipA, ipB, "different IPs must normalize to the same signature");
1269
+ assert.ok(ipA.includes("<ip>"), "normalized sig should contain <ip>");
1270
+ assert.ok(!ipA.includes("10.0.0.1"), "original IP must be replaced");
1271
+ // Hex ids (8+ chars) → <hex>
1272
+ const hexA = extractErrorSignatureFn("request id a1b2c3d4e5f6 failed");
1273
+ const hexB = extractErrorSignatureFn("request id 9f8e7d6c5b4a failed");
1274
+ assert.equal(hexA, hexB, "different hex ids must normalize to the same signature");
1275
+ assert.ok(hexA.includes("<hex>"), "normalized sig should contain <hex>");
1276
+ // "after N attempts" → "after <n> attempts"
1277
+ const rA = extractErrorSignatureFn("Retry failed after 3 attempts: boom");
1278
+ const rB = extractErrorSignatureFn("Retry failed after 5 attempts: boom");
1279
+ assert.equal(rA, rB, "different attempt counts must normalize to the same signature");
1280
+ assert.ok(rA.includes("after <n> attempts"), "normalized sig should contain 'after <n> attempts'");
1281
+ // Status codes (3-digit) survive — NOT merged by the 4+ digit rule
1282
+ const s500 = extractErrorSignatureFn("error 500 now");
1283
+ const s502 = extractErrorSignatureFn("error 502 now");
1284
+ assert.notEqual(s500, s502, "3-digit status codes must NOT merge");
1285
+ // Empty + bare 0-token error object still returns "bare-0-token-error" (R9 preserved)
1286
+ assert.equal(extractErrorSignatureFn({ stopReason: "error", usage: { inputTokens: 0, outputTokens: 0 } }), "bare-0-token-error", "R9 bare-0-token fallback must survive normalization change");
1287
+ // Empty content with no usage keeps returning ""
1288
+ assert.equal(extractErrorSignatureFn({ stopReason: "error" }), "", "bare error with no usage must return empty");
1289
+ });
1290
+ test("R12: alternating-but-equivalent marker-less errors upgrade at threshold", async () => {
1291
+ const origBackoff = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
1292
+ const origRepeat = process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD;
1293
+ process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
1294
+ process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD = "3";
1295
+ process.env.MEGACOMPACT_AUTO = "false";
1296
+ try {
1297
+ const h = harness();
1298
+ // Two texts that differ ONLY in model alias — no known-retryable markers.
1299
+ // "upstream rejected alias modal/a-b/X-1" has no network/timeout/socket/
1300
+ // 429/5xx markers in KNOWN_RETRYABLE_TRANSIENT_PATTERN.
1301
+ const textA = "upstream rejected alias modal/a-b/X-1";
1302
+ const textB = "upstream rejected alias hf/c-d/Y-2";
1303
+ // Turn 1 (textA): count → 1
1304
+ await s38TurnEnd(h, "error", textA);
1305
+ await h.fire("turn_start", { type: "turn_start", turnIndex: 2 }, h.ctx());
1306
+ await new Promise((r) => setTimeout(r, 3));
1307
+ // Turn 2 (textB): normalized matches → count → 2
1308
+ await s38TurnEnd(h, "error", textB);
1309
+ await h.fire("turn_start", { type: "turn_start", turnIndex: 3 }, h.ctx());
1310
+ await new Promise((r) => setTimeout(r, 3));
1311
+ // Turn 3 (textA again): normalized matches → count → 3 → upgrade
1312
+ await s38TurnEnd(h, "error", textA);
1313
+ await h.fire("turn_start", { type: "turn_start", turnIndex: 4 }, h.ctx());
1314
+ await new Promise((r) => setTimeout(r, 3));
1315
+ assert.ok(eventTypes(h.stateDir).includes("poisoned_context"), "equivalent alternating errors must upgrade to poisoned at threshold");
1316
+ assert.equal(h.sendUserMessages.filter((m) => m.includes("/clear")).length, 1, "exactly one /clear advise");
1317
+ }
1318
+ finally {
1319
+ if (origBackoff === undefined)
1320
+ delete process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
1321
+ else
1322
+ process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = origBackoff;
1323
+ if (origRepeat === undefined)
1324
+ delete process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD;
1325
+ else
1326
+ process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD = origRepeat;
1327
+ delete process.env.MEGACOMPACT_AUTO;
1328
+ }
1329
+ });
1330
+ test("R12: genuinely different marker-less errors do NOT merge", async () => {
1331
+ // Characterization/control test: two truly distinct error messages must NOT
1332
+ // merge under normalization — their signatures stay different and the repeat
1333
+ // counter never reaches threshold. This should pass both before and after
1334
+ // the R12 normalization change (guard against over-normalization).
1335
+ const origBackoff = process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
1336
+ const origRepeat = process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD;
1337
+ process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = "1";
1338
+ process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD = "3";
1339
+ process.env.MEGACOMPACT_AUTO = "false";
1340
+ try {
1341
+ const h = harness();
1342
+ const textA = "upstream rejected the request";
1343
+ const textB = "provider returned an empty response";
1344
+ // Alternate a,b,a,b — counter never exceeds 1.
1345
+ for (let i = 0; i < 4; i++) {
1346
+ await s38TurnEnd(h, "error", i % 2 === 0 ? textA : textB);
1347
+ await h.fire("turn_start", { type: "turn_start", turnIndex: i + 2 }, h.ctx());
1348
+ await new Promise((r) => setTimeout(r, 3));
1349
+ }
1350
+ assert.ok(!eventTypes(h.stateDir).includes("poisoned_context"), "truly different errors must NOT trigger poisoned_context");
1351
+ assert.equal(h.sendUserMessages.filter((m) => m.includes("/clear")).length, 0, "no /clear for non-repeating errors");
1352
+ }
1353
+ finally {
1354
+ if (origBackoff === undefined)
1355
+ delete process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS;
1356
+ else
1357
+ process.env.MEGACOMPACT_ERROR_RETRY_BACKOFF_MS = origBackoff;
1358
+ if (origRepeat === undefined)
1359
+ delete process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD;
1360
+ else
1361
+ process.env.MEGACOMPACT_POISONED_REPEAT_THRESHOLD = origRepeat;
1362
+ delete process.env.MEGACOMPACT_AUTO;
1363
+ }
1364
+ });
802
1365
  test("cleanup", async () => {
803
1366
  // PGlite WASM close can hang; race with a timeout to prevent 40-min hangs.
804
1367
  try {