opencode-anthropic-fix 0.1.19 → 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 CHANGED
@@ -19,6 +19,9 @@ import {
19
19
  parseShouldRetryHeader,
20
20
  TRANSIENT_RETRY_THRESHOLD_MS,
21
21
  } from "./lib/backoff.mjs";
22
+ import { callHaiku } from "./lib/haiku-call.mjs";
23
+ import { summarize as rollingSummarize } from "./lib/rolling-summarizer.mjs";
24
+ import { staleReadEviction, perToolClassPrune } from "./lib/message-transform.mjs";
22
25
 
23
26
  // ---------------------------------------------------------------------------
24
27
  // Account management CLI prompts
@@ -110,6 +113,58 @@ async function promptManageAccounts(accountManager) {
110
113
  }
111
114
  }
112
115
 
116
+ /**
117
+ * Pure driver for experimental.session.summarize. Extracted from the handler
118
+ * closure so it can be unit-tested without bootstrapping the full plugin.
119
+ * All external dependencies are injected.
120
+ *
121
+ * @param {object} deps
122
+ * @param {object|null} deps.config - Plugin config; handler no-ops if config.token_economy_strategies.haiku_rolling_summary is not true.
123
+ * @param {() => Promise<string>} deps.getAccessToken - Resolves to a Bearer OAuth token (or throws).
124
+ * @param {typeof globalThis.fetch} deps.fetchFn - HTTP transport.
125
+ * @param {typeof callHaiku} deps.callHaikuFn - Haiku API caller.
126
+ * @param {typeof rollingSummarize} deps.rollingSummarizeFn - Deterministic summarizer.
127
+ * @param {{warn: (msg: string) => void}} [deps.logger] - For fall-through warnings.
128
+ * @param {{sessionID: string, messages: unknown[], model: unknown}} input
129
+ * @param {{summary?: string, modelID?: string, providerID?: string, tokens?: {input: number, output: number}, cost?: number}} output
130
+ */
131
+ async function runHaikuSessionSummarize(
132
+ { config, getAccessToken, fetchFn, callHaikuFn, rollingSummarizeFn, logger },
133
+ input,
134
+ output,
135
+ ) {
136
+ if (!config?.token_economy_strategies?.haiku_rolling_summary) return;
137
+
138
+ try {
139
+ let capturedTokens = { input: 0, output: 0 };
140
+ let capturedCost = 0;
141
+ const haikuCall = async (request) => {
142
+ const r = await callHaikuFn({
143
+ prompt: request.prompt,
144
+ fetch: fetchFn,
145
+ getAccessToken,
146
+ });
147
+ capturedTokens = r.tokens;
148
+ capturedCost = r.cost;
149
+ return r.text;
150
+ };
151
+
152
+ const summaryText = await rollingSummarizeFn(input.messages, { haikuCall });
153
+ if (typeof summaryText !== "string" || summaryText.length === 0) return;
154
+
155
+ output.summary = summaryText;
156
+ output.modelID = "claude-haiku-4-5-20251001";
157
+ output.providerID = "anthropic";
158
+ output.tokens = capturedTokens;
159
+ output.cost = capturedCost;
160
+ } catch (err) {
161
+ if (logger && typeof logger.warn === "function") {
162
+ const msg = err && typeof err === "object" && "message" in err ? err.message : String(err);
163
+ logger.warn(`[opencode-anthropic-fix] haiku rolling summary failed; falling back to default compaction: ${msg}`);
164
+ }
165
+ }
166
+ }
167
+
113
168
  export async function AnthropicAuthPlugin({ client, project, directory, worktree, serverUrl, $ }) {
114
169
  const config = loadConfig();
115
170
  _pluginConfig = config; // expose to module-level functions (cache stats, response headers)
@@ -4004,6 +4059,24 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
4004
4059
  },
4005
4060
  ],
4006
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
+ },
4007
4080
  "experimental.session.compacting": async (input, output) => {
4008
4081
  // Reset adaptive context state on session compaction (new conversation boundary).
4009
4082
  // This prevents sticky escalation from leaking across conversations.
@@ -4045,6 +4118,41 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
4045
4118
  // lib/rolling-summarizer.mjs — call summarize(messages, {haikuCall}) here
4046
4119
  // behind config.token_economy.rolling_summarizer when messages are available.
4047
4120
  },
4121
+ /**
4122
+ * B3 L2 Option C: Plugin-generated compaction summary via Haiku.
4123
+ * Gated on token_economy_strategies.haiku_rolling_summary. See
4124
+ * runHaikuSessionSummarize at the top of this file for the full driver
4125
+ * — the closure here only binds account/token/config state.
4126
+ */
4127
+ "experimental.session.summarize": async (input, output) => {
4128
+ if (!config?.token_economy_strategies?.haiku_rolling_summary) return;
4129
+ if (!accountManager) return;
4130
+
4131
+ const account = accountManager.getCurrentAccount();
4132
+ if (!account) return;
4133
+
4134
+ const getAccessToken = async () => {
4135
+ let tok = account.access;
4136
+ if (!tok || !account.expires || account.expires < Date.now()) {
4137
+ tok = await refreshAccountTokenSingleFlight(account);
4138
+ }
4139
+ if (!tok) throw new Error("no access token available for Haiku call");
4140
+ return tok;
4141
+ };
4142
+
4143
+ await runHaikuSessionSummarize(
4144
+ {
4145
+ config,
4146
+ getAccessToken,
4147
+ fetchFn: globalThis.fetch,
4148
+ callHaikuFn: callHaiku,
4149
+ rollingSummarizeFn: rollingSummarize,
4150
+ logger: typeof console !== "undefined" ? console : undefined,
4151
+ },
4152
+ input,
4153
+ output,
4154
+ );
4155
+ },
4048
4156
  };
4049
4157
  }
4050
4158
 
@@ -8843,6 +8951,8 @@ AnthropicAuthPlugin.__testing__ = {
8843
8951
  // exposed for session-dedupe regression tests (phase C3)
8844
8952
  applySessionToolResultDedupe,
8845
8953
  maybeApplySessionToolResultDedupe,
8954
+ // exposed for experimental.session.summarize integration tests
8955
+ runHaikuSessionSummarize,
8846
8956
  get cachedCCPrompt() {
8847
8957
  return cachedCCPrompt;
8848
8958
  },
package/lib/config.mjs CHANGED
@@ -305,6 +305,23 @@ export const DEFAULT_CONFIG = {
305
305
  * 10-20% on long sessions. Pure over message history → cache-stable.
306
306
  * Off by default (conservative mode territory). */
307
307
  tool_result_dedupe_session_wide: false,
308
+ /** Haiku rolling-summary compaction: when a matching opencode fork
309
+ * exposes the `experimental.session.summarize` hook, the plugin calls
310
+ * claude-haiku-4-5-20251001 at temperature 0 to produce the compaction
311
+ * summary, bypassing the main model. Requires opencode fork support
312
+ * — no-op without it. Off by default. */
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,
308
325
  },
309
326
  /** Output cap: default max_tokens to save context window. */
310
327
  output_cap: {
@@ -781,6 +798,18 @@ function validateConfig(raw) {
781
798
  typeof tes.tool_result_dedupe_session_wide === "boolean"
782
799
  ? tes.tool_result_dedupe_session_wide
783
800
  : DEFAULT_CONFIG.token_economy_strategies.tool_result_dedupe_session_wide,
801
+ haiku_rolling_summary:
802
+ typeof tes.haiku_rolling_summary === "boolean"
803
+ ? tes.haiku_rolling_summary
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,
784
813
  };
785
814
  }
786
815
 
@@ -379,3 +379,82 @@ describe("loadConfig", () => {
379
379
  expect(config.adaptive_context.enabled).toBe(false);
380
380
  });
381
381
  });
382
+
383
+ describe("token_economy_strategies.haiku_rolling_summary", () => {
384
+ beforeEach(() => {
385
+ vi.resetAllMocks();
386
+ });
387
+
388
+ it("defaults to false in DEFAULT_CONFIG", () => {
389
+ expect(DEFAULT_CONFIG.token_economy_strategies.haiku_rolling_summary).toBe(false);
390
+ });
391
+
392
+ it("defaults to false when config file omits the flag", () => {
393
+ existsSync.mockReturnValue(true);
394
+ readFileSync.mockReturnValue(JSON.stringify({}));
395
+ const cfg = loadConfig();
396
+ expect(cfg.token_economy_strategies.haiku_rolling_summary).toBe(false);
397
+ });
398
+
399
+ it("accepts true when explicitly set in config file", () => {
400
+ existsSync.mockReturnValue(true);
401
+ readFileSync.mockReturnValue(JSON.stringify({ token_economy_strategies: { haiku_rolling_summary: true } }));
402
+ const cfg = loadConfig();
403
+ expect(cfg.token_economy_strategies.haiku_rolling_summary).toBe(true);
404
+ });
405
+
406
+ it("falls back to default on non-boolean value (graceful, does not throw)", () => {
407
+ existsSync.mockReturnValue(true);
408
+ readFileSync.mockReturnValue(JSON.stringify({ token_economy_strategies: { haiku_rolling_summary: "yes" } }));
409
+ const cfg = loadConfig();
410
+ expect(cfg.token_economy_strategies.haiku_rolling_summary).toBe(false);
411
+ });
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,44 @@
1
+ export const MODEL = "claude-haiku-4-5-20251001";
2
+ export const TEMPERATURE = 0;
3
+ export const MAX_TOKENS = 2048;
4
+ export const ANTHROPIC_VERSION = "2023-06-01";
5
+ export const API_URL = "https://api.anthropic.com/v1/messages";
6
+
7
+ // Haiku 4.5 pricing as of 2026-04-18 (USD per million tokens).
8
+ const PRICE_INPUT_PER_MTOK = 1.0;
9
+ const PRICE_OUTPUT_PER_MTOK = 5.0;
10
+
11
+ export async function callHaiku({ prompt, fetch, getAccessToken }) {
12
+ const token = await getAccessToken();
13
+
14
+ const res = await fetch(API_URL, {
15
+ method: "POST",
16
+ headers: {
17
+ "content-type": "application/json",
18
+ authorization: `Bearer ${token}`,
19
+ "anthropic-version": ANTHROPIC_VERSION,
20
+ },
21
+ body: JSON.stringify({
22
+ model: MODEL,
23
+ temperature: TEMPERATURE,
24
+ max_tokens: MAX_TOKENS,
25
+ messages: [{ role: "user", content: [{ type: "text", text: prompt }] }],
26
+ }),
27
+ });
28
+
29
+ if (!res.ok) {
30
+ const body = await res.text().catch(() => "");
31
+ throw new Error(`Haiku call failed: HTTP ${res.status} ${body}`);
32
+ }
33
+
34
+ const json = await res.json();
35
+ const textBlock = (json.content ?? []).find((b) => b.type === "text");
36
+ if (!textBlock || typeof textBlock.text !== "string" || textBlock.text.length === 0) {
37
+ throw new Error("Haiku response has no text content");
38
+ }
39
+ const input = json.usage?.input_tokens ?? 0;
40
+ const output = json.usage?.output_tokens ?? 0;
41
+ const cost = (input / 1e6) * PRICE_INPUT_PER_MTOK + (output / 1e6) * PRICE_OUTPUT_PER_MTOK;
42
+
43
+ return { text: textBlock.text, tokens: { input, output }, cost };
44
+ }
@@ -0,0 +1,72 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { callHaiku } from "./haiku-call.mjs";
3
+
4
+ describe("callHaiku", () => {
5
+ it("returns text + tokens + cost on 200 response", async () => {
6
+ const fetchMock = vi.fn(async () => ({
7
+ ok: true,
8
+ status: 200,
9
+ json: async () => ({
10
+ content: [{ type: "text", text: "summary body here" }],
11
+ usage: { input_tokens: 100, output_tokens: 30 },
12
+ }),
13
+ }));
14
+ const getAccessToken = vi.fn(async () => "oauth-token-abc");
15
+
16
+ const result = await callHaiku({
17
+ prompt: "summarize this",
18
+ fetch: fetchMock,
19
+ getAccessToken,
20
+ });
21
+
22
+ expect(result.text).toBe("summary body here");
23
+ expect(result.tokens).toEqual({ input: 100, output: 30 });
24
+ // Haiku 4.5 pricing: $1/MTok input, $5/MTok output (2026-04-18)
25
+ // cost = 100/1e6 * 1 + 30/1e6 * 5 = 0.0001 + 0.00015 = 0.00025
26
+ expect(result.cost).toBeCloseTo(0.00025, 8);
27
+
28
+ expect(fetchMock).toHaveBeenCalledTimes(1);
29
+ const [url, opts] = fetchMock.mock.calls[0];
30
+ expect(url).toBe("https://api.anthropic.com/v1/messages");
31
+ expect(opts.method).toBe("POST");
32
+ expect(opts.headers.authorization).toBe("Bearer oauth-token-abc");
33
+ expect(opts.headers["anthropic-version"]).toBe("2023-06-01");
34
+ const body = JSON.parse(opts.body);
35
+ expect(body.model).toBe("claude-haiku-4-5-20251001");
36
+ expect(body.temperature).toBe(0);
37
+ expect(body.max_tokens).toBe(2048);
38
+ expect(body.messages).toEqual([{ role: "user", content: [{ type: "text", text: "summarize this" }] }]);
39
+ });
40
+
41
+ it("throws on non-2xx HTTP response", async () => {
42
+ const fetchMock = vi.fn(async () => ({
43
+ ok: false,
44
+ status: 429,
45
+ text: async () => "rate limited",
46
+ }));
47
+ const getAccessToken = vi.fn(async () => "oauth-token-abc");
48
+
49
+ await expect(callHaiku({ prompt: "x", fetch: fetchMock, getAccessToken })).rejects.toThrow(/HTTP 429/);
50
+ });
51
+
52
+ it("throws when response content is missing or not text", async () => {
53
+ const fetchMock = vi.fn(async () => ({
54
+ ok: true,
55
+ status: 200,
56
+ json: async () => ({ content: [], usage: { input_tokens: 0, output_tokens: 0 } }),
57
+ }));
58
+ const getAccessToken = vi.fn(async () => "oauth-token-abc");
59
+
60
+ await expect(callHaiku({ prompt: "x", fetch: fetchMock, getAccessToken })).rejects.toThrow(/no text content/i);
61
+ });
62
+
63
+ it("throws when getAccessToken rejects", async () => {
64
+ const fetchMock = vi.fn();
65
+ const getAccessToken = vi.fn(async () => {
66
+ throw new Error("oauth expired");
67
+ });
68
+
69
+ await expect(callHaiku({ prompt: "x", fetch: fetchMock, getAccessToken })).rejects.toThrow(/oauth expired/);
70
+ expect(fetchMock).not.toHaveBeenCalled();
71
+ });
72
+ });
@@ -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
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-anthropic-fix",
3
- "version": "0.1.19",
3
+ "version": "0.1.21",
4
4
  "license": "GPL-3.0-or-later",
5
5
  "main": "./index.mjs",
6
6
  "files": [