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 +110 -0
- package/lib/config.mjs +29 -0
- package/lib/config.test.mjs +79 -0
- package/lib/haiku-call.mjs +44 -0
- package/lib/haiku-call.test.mjs +72 -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
|
@@ -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
|
|
package/lib/config.test.mjs
CHANGED
|
@@ -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
|
+
});
|