opencode-anthropic-fix 0.1.20 → 0.1.21
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/index.mjs +19 -0
- package/lib/config.mjs +19 -0
- package/lib/config.test.mjs +48 -0
- package/lib/message-transform.mjs +132 -0
- package/lib/message-transform.test.mjs +173 -0
- package/package.json +1 -1
package/index.mjs
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
} from "./lib/backoff.mjs";
|
|
22
22
|
import { callHaiku } from "./lib/haiku-call.mjs";
|
|
23
23
|
import { summarize as rollingSummarize } from "./lib/rolling-summarizer.mjs";
|
|
24
|
+
import { staleReadEviction, perToolClassPrune } from "./lib/message-transform.mjs";
|
|
24
25
|
|
|
25
26
|
// ---------------------------------------------------------------------------
|
|
26
27
|
// Account management CLI prompts
|
|
@@ -4058,6 +4059,24 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
|
|
|
4058
4059
|
},
|
|
4059
4060
|
],
|
|
4060
4061
|
},
|
|
4062
|
+
/**
|
|
4063
|
+
* Stateless message-list transforms. Previously fork-only patches
|
|
4064
|
+
* (4c3f4fc19 stale-read eviction, 797ae24d8 per-tool-class prune)
|
|
4065
|
+
* now live here and apply on the cloned request messages. Hook
|
|
4066
|
+
* input is `{}` — no sessionID, so these are global policies.
|
|
4067
|
+
*/
|
|
4068
|
+
"experimental.chat.messages.transform": async (_input, output) => {
|
|
4069
|
+
const strategies = config?.token_economy_strategies;
|
|
4070
|
+
if (!strategies) return;
|
|
4071
|
+
if (!output?.messages) return;
|
|
4072
|
+
|
|
4073
|
+
if (strategies.stale_read_eviction) {
|
|
4074
|
+
staleReadEviction({ messages: output.messages });
|
|
4075
|
+
}
|
|
4076
|
+
if (strategies.per_tool_class_prune) {
|
|
4077
|
+
perToolClassPrune({ messages: output.messages });
|
|
4078
|
+
}
|
|
4079
|
+
},
|
|
4061
4080
|
"experimental.session.compacting": async (input, output) => {
|
|
4062
4081
|
// Reset adaptive context state on session compaction (new conversation boundary).
|
|
4063
4082
|
// This prevents sticky escalation from leaking across conversations.
|
package/lib/config.mjs
CHANGED
|
@@ -311,6 +311,17 @@ export const DEFAULT_CONFIG = {
|
|
|
311
311
|
* summary, bypassing the main model. Requires opencode fork support
|
|
312
312
|
* — no-op without it. Off by default. */
|
|
313
313
|
haiku_rolling_summary: false,
|
|
314
|
+
/** Stale-read eviction: replace `read`/`view` tool outputs from
|
|
315
|
+
* messages older than N turns with a placeholder. Runs on every
|
|
316
|
+
* request via `experimental.chat.messages.transform`. Saves
|
|
317
|
+
* ~1-2KB per old read × conversation depth. Off by default. */
|
|
318
|
+
stale_read_eviction: false,
|
|
319
|
+
/** Per-tool-class prune thresholds: apply different token budgets
|
|
320
|
+
* to reproducible (read/grep/glob/ls) vs stateful (bash/edit/write)
|
|
321
|
+
* tool outputs during request assembly. Reproducible outputs can
|
|
322
|
+
* be re-run on demand, so they prune at a lower threshold. Off by
|
|
323
|
+
* default — when ON, uses 10_000/40_000 token budgets. */
|
|
324
|
+
per_tool_class_prune: false,
|
|
314
325
|
},
|
|
315
326
|
/** Output cap: default max_tokens to save context window. */
|
|
316
327
|
output_cap: {
|
|
@@ -791,6 +802,14 @@ function validateConfig(raw) {
|
|
|
791
802
|
typeof tes.haiku_rolling_summary === "boolean"
|
|
792
803
|
? tes.haiku_rolling_summary
|
|
793
804
|
: DEFAULT_CONFIG.token_economy_strategies.haiku_rolling_summary,
|
|
805
|
+
stale_read_eviction:
|
|
806
|
+
typeof tes.stale_read_eviction === "boolean"
|
|
807
|
+
? tes.stale_read_eviction
|
|
808
|
+
: DEFAULT_CONFIG.token_economy_strategies.stale_read_eviction,
|
|
809
|
+
per_tool_class_prune:
|
|
810
|
+
typeof tes.per_tool_class_prune === "boolean"
|
|
811
|
+
? tes.per_tool_class_prune
|
|
812
|
+
: DEFAULT_CONFIG.token_economy_strategies.per_tool_class_prune,
|
|
794
813
|
};
|
|
795
814
|
}
|
|
796
815
|
|
package/lib/config.test.mjs
CHANGED
|
@@ -410,3 +410,51 @@ describe("token_economy_strategies.haiku_rolling_summary", () => {
|
|
|
410
410
|
expect(cfg.token_economy_strategies.haiku_rolling_summary).toBe(false);
|
|
411
411
|
});
|
|
412
412
|
});
|
|
413
|
+
|
|
414
|
+
describe("token_economy_strategies.stale_read_eviction", () => {
|
|
415
|
+
beforeEach(() => {
|
|
416
|
+
vi.resetAllMocks();
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it("defaults to false in DEFAULT_CONFIG", () => {
|
|
420
|
+
expect(DEFAULT_CONFIG.token_economy_strategies.stale_read_eviction).toBe(false);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("accepts true when explicitly set", () => {
|
|
424
|
+
existsSync.mockReturnValue(true);
|
|
425
|
+
readFileSync.mockReturnValue(JSON.stringify({ token_economy_strategies: { stale_read_eviction: true } }));
|
|
426
|
+
const cfg = loadConfig();
|
|
427
|
+
expect(cfg.token_economy_strategies.stale_read_eviction).toBe(true);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it("falls back to default on non-boolean value", () => {
|
|
431
|
+
existsSync.mockReturnValue(true);
|
|
432
|
+
readFileSync.mockReturnValue(JSON.stringify({ token_economy_strategies: { stale_read_eviction: 1 } }));
|
|
433
|
+
const cfg = loadConfig();
|
|
434
|
+
expect(cfg.token_economy_strategies.stale_read_eviction).toBe(false);
|
|
435
|
+
});
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
describe("token_economy_strategies.per_tool_class_prune", () => {
|
|
439
|
+
beforeEach(() => {
|
|
440
|
+
vi.resetAllMocks();
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it("defaults to false in DEFAULT_CONFIG", () => {
|
|
444
|
+
expect(DEFAULT_CONFIG.token_economy_strategies.per_tool_class_prune).toBe(false);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it("accepts true when explicitly set", () => {
|
|
448
|
+
existsSync.mockReturnValue(true);
|
|
449
|
+
readFileSync.mockReturnValue(JSON.stringify({ token_economy_strategies: { per_tool_class_prune: true } }));
|
|
450
|
+
const cfg = loadConfig();
|
|
451
|
+
expect(cfg.token_economy_strategies.per_tool_class_prune).toBe(true);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it("falls back to default on non-boolean value", () => {
|
|
455
|
+
existsSync.mockReturnValue(true);
|
|
456
|
+
readFileSync.mockReturnValue(JSON.stringify({ token_economy_strategies: { per_tool_class_prune: "on" } }));
|
|
457
|
+
const cfg = loadConfig();
|
|
458
|
+
expect(cfg.token_economy_strategies.per_tool_class_prune).toBe(false);
|
|
459
|
+
});
|
|
460
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stateless message-list transforms applied via
|
|
3
|
+
* `experimental.chat.messages.transform` hook. The hook input is empty
|
|
4
|
+
* (`{}` — no sessionID, no agent role), so these policies are global to
|
|
5
|
+
* all sessions when enabled.
|
|
6
|
+
*
|
|
7
|
+
* The `messages` array passed to the hook is a `structuredClone` of the
|
|
8
|
+
* session state, so mutation is safe and ONLY affects the outbound
|
|
9
|
+
* request — storage is untouched.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Tool names treated as read-class (content can be re-fetched). */
|
|
13
|
+
export const STALE_READ_TOOLS = new Set(["read", "view"]);
|
|
14
|
+
|
|
15
|
+
/** Tool names trivially re-runnable (idempotent + cheap). */
|
|
16
|
+
export const REPRODUCIBLE_TOOLS = new Set(["read", "grep", "glob", "ls", "list", "find"]);
|
|
17
|
+
|
|
18
|
+
/** Tools never pruned (provider-critical, e.g. skill catalog). */
|
|
19
|
+
const PRUNE_PROTECTED_TOOLS = new Set(["skill"]);
|
|
20
|
+
|
|
21
|
+
/** Replacement text for stale read outputs. */
|
|
22
|
+
const STALE_READ_PLACEHOLDER = "[File was read earlier in this session — re-read if you need the current contents]";
|
|
23
|
+
|
|
24
|
+
/** Rough char-to-token ratio — same heuristic opencode uses for estimates. */
|
|
25
|
+
function estimateTokens(text) {
|
|
26
|
+
if (typeof text !== "string" || text.length === 0) return 0;
|
|
27
|
+
return Math.ceil(text.length / 4);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Replace read/view tool outputs from messages older than `threshold`
|
|
32
|
+
* messages-from-end with a placeholder note, and drop their attachments.
|
|
33
|
+
*
|
|
34
|
+
* Stateless. Does NOT mutate storage. Only affects the outbound request.
|
|
35
|
+
*
|
|
36
|
+
* @param {{messages: Array<{info: any, parts: any[]}>, threshold?: number, tools?: Set<string>}} args
|
|
37
|
+
* @returns {{evicted: number}} — how many tool parts were rewritten
|
|
38
|
+
*/
|
|
39
|
+
export function staleReadEviction({ messages, threshold = 10, tools = STALE_READ_TOOLS }) {
|
|
40
|
+
if (!Array.isArray(messages) || messages.length <= threshold) {
|
|
41
|
+
return { evicted: 0 };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const cutoff = messages.length - threshold;
|
|
45
|
+
let evicted = 0;
|
|
46
|
+
|
|
47
|
+
for (let i = 0; i < cutoff; i++) {
|
|
48
|
+
const msg = messages[i];
|
|
49
|
+
if (!msg?.parts) continue;
|
|
50
|
+
|
|
51
|
+
for (const part of msg.parts) {
|
|
52
|
+
if (part?.type !== "tool") continue;
|
|
53
|
+
if (part.state?.status !== "completed") continue;
|
|
54
|
+
if (part.state.time?.compacted) continue;
|
|
55
|
+
if (!tools.has(part.tool)) continue;
|
|
56
|
+
|
|
57
|
+
part.state.output = STALE_READ_PLACEHOLDER;
|
|
58
|
+
if (part.state.attachments) {
|
|
59
|
+
part.state.attachments = [];
|
|
60
|
+
}
|
|
61
|
+
evicted++;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return { evicted };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Prune older tool outputs by class, keeping the most recent N tokens
|
|
70
|
+
* worth per class (reproducible vs stateful). Walks backward from the
|
|
71
|
+
* end; once a class's running total exceeds its threshold, older outputs
|
|
72
|
+
* of that class are replaced with empty strings.
|
|
73
|
+
*
|
|
74
|
+
* Stateless. Mirrors the core `compaction.prune()` logic but applies at
|
|
75
|
+
* request-assembly time instead of persisting to storage.
|
|
76
|
+
*
|
|
77
|
+
* @param {{messages: Array<{info: any, parts: any[]}>, reproducibleThreshold?: number, statefulThreshold?: number, reproducibleTools?: Set<string>}} args
|
|
78
|
+
* @returns {{pruned: number, tokensSaved: number}}
|
|
79
|
+
*/
|
|
80
|
+
export function perToolClassPrune({
|
|
81
|
+
messages,
|
|
82
|
+
reproducibleThreshold = 10_000,
|
|
83
|
+
statefulThreshold = 40_000,
|
|
84
|
+
reproducibleTools = REPRODUCIBLE_TOOLS,
|
|
85
|
+
}) {
|
|
86
|
+
if (!Array.isArray(messages) || messages.length === 0) {
|
|
87
|
+
return { pruned: 0, tokensSaved: 0 };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let totalReproducible = 0;
|
|
91
|
+
let totalStateful = 0;
|
|
92
|
+
let pruned = 0;
|
|
93
|
+
let tokensSaved = 0;
|
|
94
|
+
|
|
95
|
+
// Walk backward through messages; within each message, also backward
|
|
96
|
+
// through parts — matches the original prune() ordering.
|
|
97
|
+
outer: for (let i = messages.length - 1; i >= 0; i--) {
|
|
98
|
+
const msg = messages[i];
|
|
99
|
+
if (!msg?.parts?.length) continue;
|
|
100
|
+
|
|
101
|
+
for (let j = msg.parts.length - 1; j >= 0; j--) {
|
|
102
|
+
const part = msg.parts[j];
|
|
103
|
+
if (part?.type !== "tool") continue;
|
|
104
|
+
if (part.state?.status !== "completed") continue;
|
|
105
|
+
if (PRUNE_PROTECTED_TOOLS.has(part.tool)) continue;
|
|
106
|
+
if (part.state.time?.compacted) break outer;
|
|
107
|
+
|
|
108
|
+
const estimate = estimateTokens(part.state.output);
|
|
109
|
+
const isReproducible = reproducibleTools.has(part.tool.toLowerCase());
|
|
110
|
+
|
|
111
|
+
if (isReproducible) {
|
|
112
|
+
totalReproducible += estimate;
|
|
113
|
+
if (totalReproducible > reproducibleThreshold) {
|
|
114
|
+
part.state.output = "";
|
|
115
|
+
if (part.state.attachments) part.state.attachments = [];
|
|
116
|
+
pruned++;
|
|
117
|
+
tokensSaved += estimate;
|
|
118
|
+
}
|
|
119
|
+
} else {
|
|
120
|
+
totalStateful += estimate;
|
|
121
|
+
if (totalStateful > statefulThreshold) {
|
|
122
|
+
part.state.output = "";
|
|
123
|
+
if (part.state.attachments) part.state.attachments = [];
|
|
124
|
+
pruned++;
|
|
125
|
+
tokensSaved += estimate;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return { pruned, tokensSaved };
|
|
132
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { staleReadEviction, perToolClassPrune, STALE_READ_TOOLS, REPRODUCIBLE_TOOLS } from "./message-transform.mjs";
|
|
3
|
+
|
|
4
|
+
/** Build a message with a single tool part. */
|
|
5
|
+
function toolMsg(tool, output, { compacted = false, attachments } = {}) {
|
|
6
|
+
return {
|
|
7
|
+
info: { role: "assistant", id: `msg-${Math.random()}` },
|
|
8
|
+
parts: [
|
|
9
|
+
{
|
|
10
|
+
type: "tool",
|
|
11
|
+
callID: "c1",
|
|
12
|
+
tool,
|
|
13
|
+
state: {
|
|
14
|
+
status: "completed",
|
|
15
|
+
input: {},
|
|
16
|
+
output,
|
|
17
|
+
title: "t",
|
|
18
|
+
metadata: {},
|
|
19
|
+
time: { start: 0, end: 1, ...(compacted ? { compacted: 1 } : {}) },
|
|
20
|
+
...(attachments ? { attachments } : {}),
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("staleReadEviction", () => {
|
|
28
|
+
it("is a no-op when messages <= threshold", () => {
|
|
29
|
+
const msgs = [toolMsg("read", "content")];
|
|
30
|
+
const { evicted } = staleReadEviction({ messages: msgs, threshold: 10 });
|
|
31
|
+
expect(evicted).toBe(0);
|
|
32
|
+
expect(msgs[0].parts[0].state.output).toBe("content");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("replaces read output older than threshold with placeholder", () => {
|
|
36
|
+
const msgs = Array.from({ length: 15 }, (_, i) => toolMsg("read", `file ${i} content`));
|
|
37
|
+
const { evicted } = staleReadEviction({ messages: msgs, threshold: 10 });
|
|
38
|
+
expect(evicted).toBe(5);
|
|
39
|
+
expect(msgs[0].parts[0].state.output).toMatch(/re-read if you need/);
|
|
40
|
+
expect(msgs[4].parts[0].state.output).toMatch(/re-read if you need/);
|
|
41
|
+
expect(msgs[5].parts[0].state.output).toBe("file 5 content");
|
|
42
|
+
expect(msgs[14].parts[0].state.output).toBe("file 14 content");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("leaves non-read tools untouched", () => {
|
|
46
|
+
const msgs = Array.from({ length: 15 }, (_, i) => toolMsg("bash", `cmd ${i} output`));
|
|
47
|
+
const { evicted } = staleReadEviction({ messages: msgs, threshold: 10 });
|
|
48
|
+
expect(evicted).toBe(0);
|
|
49
|
+
expect(msgs[0].parts[0].state.output).toBe("cmd 0 output");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("skips already-compacted tool parts", () => {
|
|
53
|
+
const msgs = [
|
|
54
|
+
toolMsg("read", "stale but compacted", { compacted: true }),
|
|
55
|
+
...Array.from({ length: 11 }, (_, i) => toolMsg("read", `x${i}`)),
|
|
56
|
+
];
|
|
57
|
+
const { evicted } = staleReadEviction({ messages: msgs, threshold: 10 });
|
|
58
|
+
expect(evicted).toBe(1);
|
|
59
|
+
expect(msgs[0].parts[0].state.output).toBe("stale but compacted");
|
|
60
|
+
expect(msgs[1].parts[0].state.output).toMatch(/re-read if you need/);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("strips attachments when evicting", () => {
|
|
64
|
+
const msgs = Array.from({ length: 15 }, (_, i) =>
|
|
65
|
+
toolMsg("read", `file ${i}`, {
|
|
66
|
+
attachments: [{ type: "file", mime: "image/png", url: "x" }],
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
69
|
+
staleReadEviction({ messages: msgs, threshold: 10 });
|
|
70
|
+
expect(msgs[0].parts[0].state.attachments).toEqual([]);
|
|
71
|
+
expect(msgs[14].parts[0].state.attachments).toHaveLength(1);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("accepts `view` as a read-class tool", () => {
|
|
75
|
+
expect(STALE_READ_TOOLS.has("view")).toBe(true);
|
|
76
|
+
const msgs = Array.from({ length: 15 }, (_, i) => toolMsg("view", `content ${i}`));
|
|
77
|
+
const { evicted } = staleReadEviction({ messages: msgs, threshold: 10 });
|
|
78
|
+
expect(evicted).toBe(5);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("handles empty/missing messages gracefully", () => {
|
|
82
|
+
expect(staleReadEviction({ messages: [] }).evicted).toBe(0);
|
|
83
|
+
expect(staleReadEviction({ messages: null }).evicted).toBe(0);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("perToolClassPrune", () => {
|
|
88
|
+
it("prunes old reproducible outputs over the 10k threshold", () => {
|
|
89
|
+
const output = "x".repeat(30_000); // ~7_500 tokens (chars/4)
|
|
90
|
+
const msgs = Array.from({ length: 3 }, () => toolMsg("read", output));
|
|
91
|
+
const { pruned, tokensSaved } = perToolClassPrune({
|
|
92
|
+
messages: msgs,
|
|
93
|
+
reproducibleThreshold: 10_000,
|
|
94
|
+
});
|
|
95
|
+
// Walk backward: newest (7_500) fits under 10k, older 2 push total over → pruned.
|
|
96
|
+
expect(pruned).toBe(2);
|
|
97
|
+
expect(tokensSaved).toBeGreaterThan(10_000);
|
|
98
|
+
expect(msgs[2].parts[0].state.output).toBe(output); // newest kept
|
|
99
|
+
expect(msgs[0].parts[0].state.output).toBe("");
|
|
100
|
+
expect(msgs[1].parts[0].state.output).toBe("");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("uses higher threshold for stateful tools (bash)", () => {
|
|
104
|
+
const mid = "x".repeat(50_000); // ~12_500 tokens
|
|
105
|
+
const msgs = Array.from({ length: 3 }, () => toolMsg("bash", mid));
|
|
106
|
+
const { pruned } = perToolClassPrune({
|
|
107
|
+
messages: msgs,
|
|
108
|
+
statefulThreshold: 40_000,
|
|
109
|
+
});
|
|
110
|
+
// 3 × 12_500 = 37_500 < 40_000 → nothing pruned
|
|
111
|
+
expect(pruned).toBe(0);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("never prunes protected tools (skill)", () => {
|
|
115
|
+
const big = "x".repeat(500_000); // ~125_000 tokens — would bust any threshold
|
|
116
|
+
const msgs = Array.from({ length: 3 }, () => toolMsg("skill", big));
|
|
117
|
+
const { pruned } = perToolClassPrune({ messages: msgs });
|
|
118
|
+
expect(pruned).toBe(0);
|
|
119
|
+
expect(msgs[0].parts[0].state.output).toBe(big);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("stops walking back when it hits a compacted part", () => {
|
|
123
|
+
const big = "x".repeat(50_000);
|
|
124
|
+
const msgs = [
|
|
125
|
+
toolMsg("read", "must-not-prune-old", { compacted: true }),
|
|
126
|
+
...Array.from({ length: 3 }, () => toolMsg("read", big)),
|
|
127
|
+
];
|
|
128
|
+
perToolClassPrune({ messages: msgs, reproducibleThreshold: 10_000 });
|
|
129
|
+
// Compacted sentinel preserved; walk terminates before reaching it.
|
|
130
|
+
expect(msgs[0].parts[0].state.output).toBe("must-not-prune-old");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("tracks reproducible and stateful budgets independently", () => {
|
|
134
|
+
const output = "x".repeat(30_000); // ~7_500 tokens each
|
|
135
|
+
// interleave: read, bash, read, bash, read, bash (oldest to newest)
|
|
136
|
+
const msgs = [
|
|
137
|
+
toolMsg("read", output),
|
|
138
|
+
toolMsg("bash", output),
|
|
139
|
+
toolMsg("read", output),
|
|
140
|
+
toolMsg("bash", output),
|
|
141
|
+
toolMsg("read", output),
|
|
142
|
+
toolMsg("bash", output),
|
|
143
|
+
];
|
|
144
|
+
const { pruned } = perToolClassPrune({
|
|
145
|
+
messages: msgs,
|
|
146
|
+
reproducibleThreshold: 10_000,
|
|
147
|
+
statefulThreshold: 40_000,
|
|
148
|
+
});
|
|
149
|
+
// reads: 7_500, 15_000 → over → prune; 22_500 → prune. 2 old reads pruned.
|
|
150
|
+
// bash: 7_500, 15_000, 22_500 → all under 40k → 0 pruned.
|
|
151
|
+
expect(pruned).toBe(2);
|
|
152
|
+
expect(msgs[4].parts[0].state.output).toBe(output); // newest read kept
|
|
153
|
+
expect(msgs[5].parts[0].state.output).toBe(output); // newest bash kept
|
|
154
|
+
expect(msgs[0].parts[0].state.output).toBe(""); // oldest read pruned
|
|
155
|
+
expect(msgs[2].parts[0].state.output).toBe(""); // middle read pruned
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("normalizes tool name case when classifying", () => {
|
|
159
|
+
expect(REPRODUCIBLE_TOOLS.has("read")).toBe(true);
|
|
160
|
+
const output = "x".repeat(30_000); // ~7_500 tokens
|
|
161
|
+
const msgs = Array.from({ length: 3 }, () => toolMsg("READ", output));
|
|
162
|
+
const { pruned } = perToolClassPrune({
|
|
163
|
+
messages: msgs,
|
|
164
|
+
reproducibleThreshold: 10_000,
|
|
165
|
+
});
|
|
166
|
+
expect(pruned).toBe(2);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("handles empty/missing messages gracefully", () => {
|
|
170
|
+
expect(perToolClassPrune({ messages: [] }).pruned).toBe(0);
|
|
171
|
+
expect(perToolClassPrune({ messages: null }).pruned).toBe(0);
|
|
172
|
+
});
|
|
173
|
+
});
|