pi-condense 2.10.1 → 2.10.3

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.
@@ -3,6 +3,7 @@ import { mkdtempSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import * as actualCompat from "@earendil-works/pi-ai/compat";
6
+ import { supersededStub } from "./supersede.js";
6
7
 
7
8
  // This must run before any module that transitively reads PI_CODING_AGENT_DIR
8
9
  // (src/config.ts's getAgentDir()) is imported/executed.
@@ -175,6 +176,7 @@ function bootExtension(
175
176
  sessionAppendCustomEntry?: (push: (type: string, data?: unknown) => void) => (type: string, data?: unknown) => string;
176
177
  branch?: any[];
177
178
  protectedTools?: string[];
179
+ protectedPaths?: string[];
178
180
  autoBudgetThreshold?: number | null;
179
181
  budgetTurnDelta?: number | null;
180
182
  frontierGapThresholdTokens?: number | null;
@@ -201,6 +203,7 @@ function bootExtension(
201
203
  // Omitted unless the test explicitly passes them, so the "default-null
202
204
  // inert" scenario can assert behavior with no key present at all (not an
203
205
  // explicit null), matching config.ts's own default.
206
+ if (options.protectedPaths !== undefined) contextPruneSettings.protectedPaths = options.protectedPaths;
204
207
  if (options.budgetTurnDelta !== undefined) contextPruneSettings.budgetTurnDelta = options.budgetTurnDelta;
205
208
  if (options.frontierGapThresholdTokens !== undefined) {
206
209
  contextPruneSettings.frontierGapThresholdTokens = options.frontierGapThresholdTokens;
@@ -916,3 +919,518 @@ describe("reload rearm (issue #6)", () => {
916
919
  expect(secondFrontier.lastAttemptedTimestamp).toBeGreaterThan(firstFrontier.lastAttemptedTimestamp);
917
920
  });
918
921
  });
922
+
923
+ describe("supersede floor cadence (spec 2026-09-07)", () => {
924
+ const PROTECTED_PATH = "/x/skills/a/SKILL.md";
925
+ const PROTECTED_GLOB = "**/skills/**/*.md";
926
+
927
+ function protectedRead(id: string, ts: number, text = `content-${id}`): any[] {
928
+ return [
929
+ {
930
+ type: "message",
931
+ message: { role: "assistant", content: [{ type: "toolCall", id, name: "read", arguments: { path: PROTECTED_PATH } }] },
932
+ },
933
+ {
934
+ type: "message",
935
+ message: { role: "toolResult", toolCallId: id, toolName: "read", content: [{ type: "text", text }], timestamp: ts },
936
+ },
937
+ ];
938
+ }
939
+
940
+ function bashCall(id: string, ts: number, text = "x".repeat(400)): any[] {
941
+ return [
942
+ { type: "message", message: { role: "assistant", content: [{ type: "toolCall", id, name: "bash", arguments: {} }] } },
943
+ { type: "message", message: { role: "toolResult", toolCallId: id, toolName: "bash", content: [{ type: "text", text }], timestamp: ts } },
944
+ ];
945
+ }
946
+
947
+ async function render(branch: any[], handlers: Map<string, any>, ctx: any): Promise<any[]> {
948
+ const rawMessages = branch.filter((e) => e.type === "message").map((e) => e.message);
949
+ const res = await handlers.get("context")!({ messages: rawMessages }, ctx);
950
+ return res?.messages ?? rawMessages;
951
+ }
952
+
953
+ function toolResultText(messages: any[], toolCallId: string): string | undefined {
954
+ const m = messages.find((m: any) => m.role === "toolResult" && m.toolCallId === toolCallId);
955
+ if (!m) return undefined;
956
+ return Array.isArray(m.content) ? m.content.map((c: any) => c.text).join("\n") : String(m.content);
957
+ }
958
+
959
+ it("no rewrite between two protected reads leaves both verbatim", async () => {
960
+ const branch: any[] = [];
961
+ const { handlers, ctx } = await boot({ protectedPaths: [PROTECTED_GLOB], branch });
962
+
963
+ branch.push(...protectedRead("r1", 10));
964
+ await handlers.get("session_start")!({}, ctx);
965
+ // Prime: consumes the session_start floor=0 while only the first read
966
+ // exists (no candidate yet), so it cannot spuriously activate anything.
967
+ await render(branch, handlers, ctx);
968
+
969
+ branch.push(...protectedRead("r2", 30));
970
+ // No flush/compression/cold-cache event between the two reads.
971
+ const rendered = await render(branch, handlers, ctx);
972
+
973
+ expect(toolResultText(rendered, "r1")).toBe("content-r1");
974
+ expect(toolResultText(rendered, "r2")).toBe("content-r2");
975
+ });
976
+
977
+ it("an indexed flush whose unprotected result precedes the older read stubs it", async () => {
978
+ const branch: any[] = [];
979
+ const { handlers, ctx, appended } = await boot({ protectedPaths: [PROTECTED_GLOB], branch });
980
+
981
+ branch.push(...bashCall("bash1", 10));
982
+ branch.push(...protectedRead("r1", 20));
983
+ await handlers.get("session_start")!({}, ctx);
984
+ await render(branch, handlers, ctx); // prime: consume floor=0, single occurrence so far
985
+
986
+ branch.push(...protectedRead("r2", 30));
987
+
988
+ // bootExtension's default ctx.getContextUsage() is 0.6, above the 0.5
989
+ // autoBudgetThreshold, so this turn_end flushes immediately.
990
+ await handlers.get("turn_end")!(
991
+ {
992
+ message: { role: "assistant", content: [{ type: "toolCall", id: "bash1", name: "bash", arguments: {} }] },
993
+ toolResults: [
994
+ { role: "toolResult", toolCallId: "bash1", toolName: "bash", content: [{ type: "text", text: "x".repeat(400) }], timestamp: 10 },
995
+ ],
996
+ turnIndex: 1,
997
+ },
998
+ ctx,
999
+ );
1000
+
1001
+ expect(appended.some((e) => e.type === "context-prune-index")).toBe(true);
1002
+
1003
+ const rendered = await render(branch, handlers, ctx);
1004
+ expect(toolResultText(rendered, "r1")).toBe(supersededStub(PROTECTED_PATH));
1005
+ expect(toolResultText(rendered, "r2")).toBe("content-r2");
1006
+ // Phase 1 (unrelated to supersede) already stubs bash1's own result.
1007
+ expect(toolResultText(rendered, "bash1")).not.toBe("x".repeat(400));
1008
+ });
1009
+
1010
+ it("an indexed flush whose unprotected result follows the older read leaves it verbatim (floor is positional)", async () => {
1011
+ const branch: any[] = [];
1012
+ const { handlers, ctx, appended } = await boot({ protectedPaths: [PROTECTED_GLOB], branch });
1013
+
1014
+ branch.push(...protectedRead("r1", 10));
1015
+ await handlers.get("session_start")!({}, ctx);
1016
+ await render(branch, handlers, ctx); // prime
1017
+
1018
+ branch.push(...bashCall("bash1", 20));
1019
+ branch.push(...protectedRead("r2", 30));
1020
+
1021
+ await handlers.get("turn_end")!(
1022
+ {
1023
+ message: { role: "assistant", content: [{ type: "toolCall", id: "bash1", name: "bash", arguments: {} }] },
1024
+ toolResults: [
1025
+ { role: "toolResult", toolCallId: "bash1", toolName: "bash", content: [{ type: "text", text: "x".repeat(400) }], timestamp: 20 },
1026
+ ],
1027
+ turnIndex: 1,
1028
+ },
1029
+ ctx,
1030
+ );
1031
+
1032
+ expect(appended.some((e) => e.type === "context-prune-index")).toBe(true);
1033
+
1034
+ const rendered = await render(branch, handlers, ctx);
1035
+ // Floor = 20 (bash1's resultTimestamp); r1's timestamp (10) is before it,
1036
+ // so the floor never reaches it even though a real rewrite just happened.
1037
+ expect(toolResultText(rendered, "r1")).toBe("content-r1");
1038
+ expect(toolResultText(rendered, "r2")).toBe("content-r2");
1039
+ });
1040
+
1041
+ it("a protected-only turn sets no floor", async () => {
1042
+ const branch: any[] = [];
1043
+ const { handlers, ctx, appended } = await boot({ protectedPaths: [PROTECTED_GLOB], branch });
1044
+
1045
+ branch.push(...protectedRead("r1", 10));
1046
+ await handlers.get("session_start")!({}, ctx);
1047
+ await render(branch, handlers, ctx); // prime
1048
+
1049
+ branch.push(...protectedRead("r2", 30));
1050
+
1051
+ await handlers.get("turn_end")!(
1052
+ {
1053
+ message: {
1054
+ role: "assistant",
1055
+ content: [
1056
+ { type: "toolCall", id: "r1", name: "read", arguments: { path: PROTECTED_PATH } },
1057
+ { type: "toolCall", id: "r2", name: "read", arguments: { path: PROTECTED_PATH } },
1058
+ ],
1059
+ },
1060
+ toolResults: [
1061
+ { role: "toolResult", toolCallId: "r1", toolName: "read", content: [{ type: "text", text: "content-r1" }], timestamp: 10 },
1062
+ { role: "toolResult", toolCallId: "r2", toolName: "read", content: [{ type: "text", text: "content-r2" }], timestamp: 30 },
1063
+ ],
1064
+ turnIndex: 1,
1065
+ },
1066
+ ctx,
1067
+ );
1068
+
1069
+ expect(appended.some((e) => e.type === "context-prune-index")).toBe(false);
1070
+
1071
+ const rendered = await render(branch, handlers, ctx);
1072
+ expect(toolResultText(rendered, "r1")).toBe("content-r1");
1073
+ expect(toolResultText(rendered, "r2")).toBe("content-r2");
1074
+ });
1075
+
1076
+ const COLD_CACHE_EVENTS = ["session_start", "session_tree", "model_select", "session_compact", "thinking_level_select"];
1077
+
1078
+ for (const eventName of COLD_CACHE_EVENTS) {
1079
+ it(`${eventName} activates every pending supersession (floor = 0)`, async () => {
1080
+ const branch: any[] = [];
1081
+ const { handlers, ctx } = await boot({ protectedPaths: [PROTECTED_GLOB], branch });
1082
+
1083
+ branch.push(...protectedRead("r1", 10));
1084
+ await handlers.get("session_start")!({}, ctx);
1085
+ await render(branch, handlers, ctx); // prime
1086
+
1087
+ branch.push(...protectedRead("r2", 30));
1088
+ const beforeEvent = await render(branch, handlers, ctx);
1089
+ expect(toolResultText(beforeEvent, "r1")).toBe("content-r1");
1090
+ expect(toolResultText(beforeEvent, "r2")).toBe("content-r2");
1091
+
1092
+ await handlers.get(eventName)!({}, ctx);
1093
+
1094
+ const afterEvent = await render(branch, handlers, ctx);
1095
+ expect(toolResultText(afterEvent, "r1")).toBe(supersededStub(PROTECTED_PATH));
1096
+ expect(toolResultText(afterEvent, "r2")).toBe("content-r2");
1097
+ });
1098
+ }
1099
+
1100
+ it("stays sticky after cold-cache activation: the older read stays stubbed on a later render with no new event", async () => {
1101
+ const branch: any[] = [];
1102
+ const { handlers, ctx } = await boot({ protectedPaths: [PROTECTED_GLOB], branch });
1103
+
1104
+ branch.push(...protectedRead("r1", 10));
1105
+ await handlers.get("session_start")!({}, ctx);
1106
+ await render(branch, handlers, ctx); // prime
1107
+
1108
+ branch.push(...protectedRead("r2", 30));
1109
+ await handlers.get("model_select")!({}, ctx);
1110
+
1111
+ const first = await render(branch, handlers, ctx);
1112
+ expect(toolResultText(first, "r1")).toBe(supersededStub(PROTECTED_PATH));
1113
+
1114
+ const second = await render(branch, handlers, ctx);
1115
+ expect(toolResultText(second, "r1")).toBe(supersededStub(PROTECTED_PATH));
1116
+ expect(toolResultText(second, "r2")).toBe("content-r2");
1117
+ });
1118
+
1119
+ it("chain compression lowers the floor: the older read stubs inside the compressed chain's <protected-output>, the newer read stays raw", async () => {
1120
+ // Chain 0 mixes an unprotected bash call with the OLD protected read in the
1121
+ // same turn, so the chain gets a real per-batch summary (a fully-protected
1122
+ // chain is intentionally never compressed - chain-compressor.ts fullyProtected
1123
+ // guard) while still carrying protectedToolCallIds for the read.
1124
+ function closedChain(index: number, extraToolCalls: { id: string; name: string; args: any; text: string }[], startTs: number) {
1125
+ const msgs: any[] = [];
1126
+ let t = startTs;
1127
+ msgs.push({ type: "message", message: { role: "user", content: [{ type: "text", text: `do task ${index}` }], timestamp: t } });
1128
+ msgs.push({
1129
+ type: "message",
1130
+ message: {
1131
+ role: "assistant",
1132
+ content: extraToolCalls.map((c) => ({ type: "toolCall", id: c.id, name: c.name, arguments: c.args })),
1133
+ },
1134
+ });
1135
+ for (const c of extraToolCalls) {
1136
+ t += 100;
1137
+ msgs.push({
1138
+ type: "message",
1139
+ message: { role: "toolResult", toolCallId: c.id, toolName: c.name, content: [{ type: "text", text: c.text }], timestamp: t },
1140
+ });
1141
+ }
1142
+ t += 1000;
1143
+ msgs.push({ type: "message", message: { role: "assistant", content: [{ type: "text", text: `done ${index}` }], timestamp: t } });
1144
+ return { msgs, startUserTimestamp: startTs };
1145
+ }
1146
+
1147
+ const chain0 = closedChain(
1148
+ 0,
1149
+ [
1150
+ { id: "c0-bash", name: "bash", args: {}, text: "x".repeat(400) },
1151
+ { id: "c0-read", name: "read", args: { path: PROTECTED_PATH }, text: "content-r1" },
1152
+ ],
1153
+ 1000,
1154
+ );
1155
+ const chain1 = closedChain(1, [{ id: "c1-bash", name: "bash", args: {}, text: "y".repeat(400) }], 5000);
1156
+ const chain2 = closedChain(2, [{ id: "c2-bash", name: "bash", args: {}, text: "z".repeat(400) }], 9000);
1157
+ const chain3 = closedChain(3, [{ id: "c3-bash", name: "bash", args: {}, text: "w".repeat(400) }], 13000);
1158
+
1159
+ const branch: any[] = [...chain0.msgs, ...chain1.msgs, ...chain2.msgs, ...chain3.msgs];
1160
+ const { handlers, ctx, appended } = await boot({
1161
+ protectedPaths: [PROTECTED_GLOB],
1162
+ chainCompressionEnabled: true,
1163
+ rollingWindow: 3,
1164
+ branch,
1165
+ });
1166
+
1167
+ await handlers.get("session_start")!({}, ctx);
1168
+ await render(branch, handlers, ctx); // prime: only one occurrence of the protected path exists so far
1169
+
1170
+ // message_end drives an unconditional agent-message flush: summarizes the
1171
+ // four turns, then compresses whichever chains fall outside rollingWindow=3
1172
+ // (chain0, the oldest of four).
1173
+ await handlers.get("message_end")!(
1174
+ { message: { role: "assistant", content: [{ type: "text", text: "done" }] } },
1175
+ ctx,
1176
+ );
1177
+
1178
+ const chainEntries = appended.filter((e) => e.type === "context-prune-chain");
1179
+ const chain0Entry = chainEntries.find((e) => (e.data as any).startUserTimestamp === chain0.startUserTimestamp);
1180
+ if (!chain0Entry) {
1181
+ throw new Error(`chain0 was not compressed - chainEntries: ${JSON.stringify(chainEntries.map((e) => e.data))}`);
1182
+ }
1183
+ expect(((chain0Entry.data as any).protectedToolCallIds ?? []).includes("c0-read")).toBe(true);
1184
+
1185
+ // The newer read of the same path, added after compression - never indexed,
1186
+ // never compressed, must stay the winner.
1187
+ branch.push(...protectedRead("r2", 20000));
1188
+
1189
+ const rendered = await render(branch, handlers, ctx);
1190
+
1191
+ const compressedChainMsg = rendered.find(
1192
+ (m: any) => m.role === "user" && Array.isArray(m.content) && m.content[0]?.text?.includes(`id="${(chain0Entry.data as any).blockId}"`),
1193
+ );
1194
+ expect(compressedChainMsg).toBeDefined();
1195
+ const chainText = compressedChainMsg.content[0].text as string;
1196
+ expect(chainText).toContain('<protected-output tool="read">');
1197
+ expect(chainText).toContain(supersededStub(PROTECTED_PATH));
1198
+
1199
+ expect(toolResultText(rendered, "r2")).toBe("content-r2");
1200
+ });
1201
+
1202
+ it("a skipped-trivial batch (below minBatchChars) sets no floor", async () => {
1203
+ const branch: any[] = [];
1204
+ const { handlers, ctx, appended } = await boot({ protectedPaths: [PROTECTED_GLOB], branch });
1205
+
1206
+ // minBatchChars is hardcoded to 1 in this harness's settings fixture, so an
1207
+ // empty result (0 raw chars) is the only way to land below it and take the
1208
+ // trivial path instead of an actual LLM call.
1209
+ branch.push(...bashCall("bash1", 10, ""));
1210
+ branch.push(...protectedRead("r1", 20));
1211
+ await handlers.get("session_start")!({}, ctx);
1212
+ await render(branch, handlers, ctx); // prime
1213
+
1214
+ branch.push(...protectedRead("r2", 30));
1215
+
1216
+ await handlers.get("turn_end")!(
1217
+ {
1218
+ message: { role: "assistant", content: [{ type: "toolCall", id: "bash1", name: "bash", arguments: {} }] },
1219
+ toolResults: [
1220
+ { role: "toolResult", toolCallId: "bash1", toolName: "bash", content: [{ type: "text", text: "" }], timestamp: 10 },
1221
+ ],
1222
+ turnIndex: 1,
1223
+ },
1224
+ ctx,
1225
+ );
1226
+
1227
+ expect(appended.some((e) => e.type === "context-prune-index")).toBe(false);
1228
+ const frontierEntries = appended.filter((e) => e.type === "context-prune-frontier");
1229
+ expect(frontierEntries.length).toBe(1);
1230
+ expect((frontierEntries[0].data as any).outcome).toBe("skipped-trivial");
1231
+
1232
+ const rendered = await render(branch, handlers, ctx);
1233
+ expect(toolResultText(rendered, "r1")).toBe("content-r1");
1234
+ expect(toolResultText(rendered, "r2")).toBe("content-r2");
1235
+ });
1236
+
1237
+ it("a skipped-oversized batch (summary longer than raw) sets no floor", async () => {
1238
+ const branch: any[] = [];
1239
+ const { handlers, ctx, appended } = await boot({ protectedPaths: [PROTECTED_GLOB], branch });
1240
+
1241
+ // A 1-char raw result clears minBatchChars=1 (not trivial) but the mocked
1242
+ // summarizer's decorated output is always longer than 1 char, so
1243
+ // shouldSkipOversized (index.ts) fires and the batch never indexes.
1244
+ branch.push(...bashCall("bash1", 10, "x"));
1245
+ branch.push(...protectedRead("r1", 20));
1246
+ await handlers.get("session_start")!({}, ctx);
1247
+ await render(branch, handlers, ctx); // prime
1248
+
1249
+ branch.push(...protectedRead("r2", 30));
1250
+
1251
+ await handlers.get("turn_end")!(
1252
+ {
1253
+ message: { role: "assistant", content: [{ type: "toolCall", id: "bash1", name: "bash", arguments: {} }] },
1254
+ toolResults: [
1255
+ { role: "toolResult", toolCallId: "bash1", toolName: "bash", content: [{ type: "text", text: "x" }], timestamp: 10 },
1256
+ ],
1257
+ turnIndex: 1,
1258
+ },
1259
+ ctx,
1260
+ );
1261
+
1262
+ expect(appended.some((e) => e.type === "context-prune-index")).toBe(false);
1263
+ const frontierEntries = appended.filter((e) => e.type === "context-prune-frontier");
1264
+ expect(frontierEntries.length).toBe(1);
1265
+ expect((frontierEntries[0].data as any).outcome).toBe("skipped-oversized");
1266
+
1267
+ const rendered = await render(branch, handlers, ctx);
1268
+ expect(toolResultText(rendered, "r1")).toBe("content-r1");
1269
+ expect(toolResultText(rendered, "r2")).toBe("content-r2");
1270
+ });
1271
+
1272
+ it("a dedup alias registered in a fully-deduped (skipped-deduped) batch still lowers the floor", async () => {
1273
+ // Batch 1 (turn 1): a real bash result R, flushed and indexed normally.
1274
+ // Its own resultTimestamp (500) is deliberately AFTER the older protected
1275
+ // read (100), so on its own it could never explain that read's
1276
+ // activation. Batch 2 (turn 2) resends the identical bash content at an
1277
+ // EARLIER timestamp (90, <= the older read's 100) — content-hash dedup
1278
+ // (indexer.lookupByContent/registerDuplicate) turns it into a pure alias,
1279
+ // no LLM call, batch outcome skipped-deduped — but the alias's own
1280
+ // timestamp still feeds floorSources (index.ts, unconditionally, before
1281
+ // the per-batch outcome switch), so it alone must be what activates the
1282
+ // supersession, isolating G4's "alias counts regardless of outcome" claim
1283
+ // from the ordinary indexed-flush floor path already covered above.
1284
+ const R = "R".repeat(400);
1285
+ const branch: any[] = [];
1286
+ const { handlers, ctx, appended } = await boot({ protectedPaths: [PROTECTED_GLOB], branch });
1287
+
1288
+ branch.push(...protectedRead("r_old", 100));
1289
+ await handlers.get("session_start")!({}, ctx);
1290
+ await render(branch, handlers, ctx); // prime: single occurrence so far
1291
+
1292
+ branch.push(...bashCall("bashOrig", 500, R));
1293
+ await handlers.get("turn_end")!(
1294
+ {
1295
+ message: { role: "assistant", content: [{ type: "toolCall", id: "bashOrig", name: "bash", arguments: {} }] },
1296
+ toolResults: [{ role: "toolResult", toolCallId: "bashOrig", toolName: "bash", content: [{ type: "text", text: R }], timestamp: 500 }],
1297
+ turnIndex: 1,
1298
+ },
1299
+ ctx,
1300
+ );
1301
+ expect(appended.some((e) => e.type === "context-prune-index")).toBe(true);
1302
+
1303
+ branch.push(...bashCall("bashDup", 90, R));
1304
+ await handlers.get("turn_end")!(
1305
+ {
1306
+ message: { role: "assistant", content: [{ type: "toolCall", id: "bashDup", name: "bash", arguments: {} }] },
1307
+ toolResults: [{ role: "toolResult", toolCallId: "bashDup", toolName: "bash", content: [{ type: "text", text: R }], timestamp: 90 }],
1308
+ turnIndex: 2,
1309
+ },
1310
+ ctx,
1311
+ );
1312
+
1313
+ const dedupAliasEntries = appended.filter((e) => e.type === "context-prune-dedup-alias");
1314
+ expect(dedupAliasEntries.length).toBe(1);
1315
+ const frontierEntries = appended.filter((e) => e.type === "context-prune-frontier");
1316
+ expect(frontierEntries[frontierEntries.length - 1].data && (frontierEntries[frontierEntries.length - 1].data as any).outcome).toBe(
1317
+ "skipped-deduped",
1318
+ );
1319
+ // The alias's own resultTimestamp (90) is <= the older read's (100) — the
1320
+ // property that lets it, alone, explain the activation below.
1321
+
1322
+ branch.push(...protectedRead("r_new", 99999));
1323
+ const rendered = await render(branch, handlers, ctx);
1324
+
1325
+ expect(toolResultText(rendered, "r_old")).toBe(supersededStub(PROTECTED_PATH));
1326
+ expect(toolResultText(rendered, "r_new")).toBe("content-r_new");
1327
+ });
1328
+
1329
+ it("a chain-compression floor (anchor timestamp) is distinguishable from the indexed-result floor", async () => {
1330
+ // Every unprotected indexed result across all four chains (5000/7000/8000)
1331
+ // is timestamped AFTER the older protected read (100). Only chain0's
1332
+ // startUserTimestamp (10, the compression anchor) is timestamped before
1333
+ // it — so only compressEligible's separate lowerFloor(startUserTimestamp)
1334
+ // call (index.ts, after the per-batch flush's own lowerFloor call) can
1335
+ // explain the older read's activation, isolating the chain-anchor floor
1336
+ // source from the ordinary indexed-batch floor source already covered
1337
+ // above.
1338
+ function closedChain(startTs: number, toolCalls: { id: string; name: string; args: any; text: string }[]) {
1339
+ const msgs: any[] = [];
1340
+ msgs.push({ type: "message", message: { role: "user", content: [{ type: "text", text: `task ${startTs}` }], timestamp: startTs } });
1341
+ msgs.push({
1342
+ type: "message",
1343
+ message: { role: "assistant", content: toolCalls.map((c) => ({ type: "toolCall", id: c.id, name: c.name, arguments: c.args })) },
1344
+ });
1345
+ for (const c of toolCalls) {
1346
+ msgs.push({
1347
+ type: "message",
1348
+ message: { role: "toolResult", toolCallId: c.id, toolName: c.name, content: [{ type: "text", text: c.text }], timestamp: (c as any).ts },
1349
+ });
1350
+ }
1351
+ msgs.push({ type: "message", message: { role: "assistant", content: [{ type: "text", text: `done ${startTs}` }], timestamp: startTs + 1 } });
1352
+ return msgs;
1353
+ }
1354
+
1355
+ const chain0 = closedChain(10, [{ id: "c0-bash", name: "bash", args: {}, text: "x".repeat(400), ts: 5000 } as any]);
1356
+ const chain1 = closedChain(6000, [{ id: "c1-read", name: "read", args: { path: PROTECTED_PATH }, text: "content-r_old", ts: 100 } as any]);
1357
+ const chain2 = closedChain(9000, [{ id: "c2-bash", name: "bash", args: {}, text: "y".repeat(400), ts: 7000 } as any]);
1358
+ const chain3 = closedChain(13000, [{ id: "c3-bash", name: "bash", args: {}, text: "z".repeat(400), ts: 8000 } as any]);
1359
+
1360
+ const branch: any[] = [...chain0, ...chain1, ...chain2, ...chain3];
1361
+ const { handlers, ctx, appended } = await boot({
1362
+ protectedPaths: [PROTECTED_GLOB],
1363
+ chainCompressionEnabled: true,
1364
+ rollingWindow: 3,
1365
+ branch,
1366
+ });
1367
+
1368
+ await handlers.get("session_start")!({}, ctx);
1369
+ await render(branch, handlers, ctx); // prime: only c1-read exists so far, no second occurrence
1370
+
1371
+ await handlers.get("message_end")!(
1372
+ { message: { role: "assistant", content: [{ type: "text", text: "done" }] } },
1373
+ ctx,
1374
+ );
1375
+
1376
+ const chainEntries = appended.filter((e) => e.type === "context-prune-chain");
1377
+ const chain0Entry = chainEntries.find((e) => (e.data as any).startUserTimestamp === 10);
1378
+ if (!chain0Entry) {
1379
+ throw new Error(`chain0 was not compressed - chainEntries: ${JSON.stringify(chainEntries.map((e) => e.data))}`);
1380
+ }
1381
+ expect((chain0Entry.data as any).startUserTimestamp).toBeLessThanOrEqual(100);
1382
+
1383
+ branch.push(...protectedRead("r_new", 99999));
1384
+ const rendered = await render(branch, handlers, ctx);
1385
+
1386
+ expect(toolResultText(rendered, "c1-read")).toBe(supersededStub(PROTECTED_PATH));
1387
+ expect(toolResultText(rendered, "r_new")).toBe("content-r_new");
1388
+ });
1389
+
1390
+ it("combined lowering is monotonic: a later, higher-timestamp floor source cannot raise floor back up", async () => {
1391
+ // The task's suggested shape (one indexed flush + one compression in the
1392
+ // same turn) is exercised above by the chain-anchor test; this covers the
1393
+ // explicitly-allowed alternative instead: two floor sources, in either
1394
+ // order, across separate turns — the low value (10) is set first, the
1395
+ // high value (1000) arrives after, and the floor must stay clamped at the
1396
+ // min (10), not get overwritten by the later, higher call.
1397
+ const branch: any[] = [];
1398
+ const { handlers, ctx } = await boot({ protectedPaths: [PROTECTED_GLOB], branch });
1399
+
1400
+ branch.push(...protectedRead("r_mid", 500));
1401
+ await handlers.get("session_start")!({}, ctx);
1402
+ await render(branch, handlers, ctx); // prime: single occurrence so far
1403
+
1404
+ branch.push(...bashCall("bashLow", 10));
1405
+ await handlers.get("turn_end")!(
1406
+ {
1407
+ message: { role: "assistant", content: [{ type: "toolCall", id: "bashLow", name: "bash", arguments: {} }] },
1408
+ toolResults: [
1409
+ { role: "toolResult", toolCallId: "bashLow", toolName: "bash", content: [{ type: "text", text: "x".repeat(400) }], timestamp: 10 },
1410
+ ],
1411
+ turnIndex: 1,
1412
+ },
1413
+ ctx,
1414
+ );
1415
+
1416
+ branch.push(...bashCall("bashHigh", 1000));
1417
+ await handlers.get("turn_end")!(
1418
+ {
1419
+ message: { role: "assistant", content: [{ type: "toolCall", id: "bashHigh", name: "bash", arguments: {} }] },
1420
+ toolResults: [
1421
+ { role: "toolResult", toolCallId: "bashHigh", toolName: "bash", content: [{ type: "text", text: "y".repeat(400) }], timestamp: 1000 },
1422
+ ],
1423
+ turnIndex: 2,
1424
+ },
1425
+ ctx,
1426
+ );
1427
+
1428
+ branch.push(...protectedRead("r_new", 99999));
1429
+ const rendered = await render(branch, handlers, ctx);
1430
+
1431
+ // r_mid (500) sits between the low floor source (10) and the high one
1432
+ // (1000): only a floor still clamped at 10 explains its activation.
1433
+ expect(toolResultText(rendered, "r_mid")).toBe(supersededStub(PROTECTED_PATH));
1434
+ expect(toolResultText(rendered, "r_new")).toBe("content-r_new");
1435
+ });
1436
+ });