From 52567acbb95356700e4eb4e119c31208611394d2 Mon Sep 17 00:00:00 2001 From: awork Date: Wed, 9 Sep 2026 14:25:40 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20compress.reasoning=20=E2=80=94=20drop?= =?UTF-8?q?=20oversized=20thinking=20from=20closed-turn=20compress=20calls?= =?UTF-8?q?=20(#336)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exact semantic alignment with opencode-acp #377 (owner-locked design): compress calls are hard-exempt from compression, so their thinking rides along every request as an unreclaimable context floor. A request-time pass (src/reasoning-drop.ts) removes thinking parts only when ALL gates hold: closed turn (strictly before the last genuine user message), compress toolCall selector, and total reasoning length strictly exceeds threshold chars (2048 default, summed per-message; 0 drops any non-empty). The active round is never touched; persisted history is never modified; pure/idempotent/fail-safe. Config: compress.reasoning { drop, threshold } merged field-wise across the existing three levels (model > provider > global). OpenAI-style opaque reasoning opts out per-provider: { "compress": { "providers": { "openai": { "reasoning": { "drop": false } } } } } Applied to the rebuilt outgoing view BEFORE the nudge push so a synthetic user-role nudge can never become the boundary. Fixes #336 --- CONFIGURATION.md | 24 +++++- CONFIGURATION.zh-CN.md | 25 +++++- src/config.ts | 9 +++ src/index.ts | 16 +++- src/reasoning-drop.ts | 100 ++++++++++++++++++++++++ src/runtime.ts | 14 +++- tests/reasoning-drop.test.ts | 145 +++++++++++++++++++++++++++++++++++ 7 files changed, 328 insertions(+), 5 deletions(-) create mode 100644 src/reasoning-drop.ts create mode 100644 tests/reasoning-drop.test.ts diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 44ade3a..857d7df 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -48,7 +48,8 @@ Create `~/.pi/acp.json` (or `/.pi/acp.json`) and drop in whichever keys "compress": { "maxContextLimit": "75%", "emergencyThresholdPercent": "95%", - "nudgeGrowthTokens": 50000 + "nudgeGrowthTokens": 50000, + "reasoning": { "drop": true, "threshold": 2048 } } } ``` @@ -141,6 +142,7 @@ All keys below are currently **ACTIVE**. | `compress.maxContextLimit` | number \| string | `"75%"` | 🟢 ACTIVE | Context threshold that triggers forced compression nudges. | | `compress.emergencyThresholdPercent` | number \| string | `"95%"` | 🟢 ACTIVE | Context threshold that triggers emergency truncation. | | `compress.nudgeGrowthTokens` | number | `50000` | 🟢 ACTIVE | Token growth step for soft compression nudges. | +| `compress.reasoning` | object | `{ "drop": true, "threshold": 2048 }` | 🟢 ACTIVE | Drop oversized thinking from historical `compress` calls (request-time; persisted history untouched). | **Prompts keys** @@ -457,6 +459,26 @@ The flow is: - **Description:** The token-growth threshold that controls the cadence of **soft** compression nudges. A soft nudge fires roughly every time this many tokens of new compressible content accumulate. A lower value means the model is nudged to compress more often; a higher value means less frequent nudges. This only governs *growth-driven* nudges — once usage crosses `compress.maxContextLimit`, forced nudges take over regardless of this setting. Maps to the kernel settings `nudge.growthFloor` and `nudge.growthCap`. - **Same-turn re-inject:** within one user turn a nudge injects at most once, but once the context has since grown by a full growth floor (mirroring the kernel's anti-thrashing cadence: `max(minGrowthFloor, minGrowthRatio × adaptiveGrowth)` — 22.5K tokens with defaults) a fresh reminder re-injects in the same turn (issue #269: a model that ignored a 78% nudge used to stay silent until the 95% emergency truncation). After a successful compress the growth baseline re-anchors to the new (smaller) scale, so post-compress regrowth into the pressure band is not held against the pre-compress peak. +### `compress.reasoning` + +- **Type:** `object` — `{ "drop": boolean, "threshold": number }` +- **Default:** `{ "drop": true, "threshold": 2048 }` +- **Status:** 🟢 ACTIVE +- **Description:** Config for dropping oversized reasoning (thinking) parts from historical `compress` tool calls — exact semantic alignment with [opencode-acp #377](https://github.com/ranxianglei/opencode-acp/pull/377). `compress` calls are hard-exempt from compression (their tool results anchor the block summaries), so their thinking rides along every request as an unreclaimable context floor. A request-time pass removes `thinking` parts from a message only when **all** gates hold: + 1. **Closed turn** — the message is strictly before the last genuine user message; the active round is never touched (some providers require replaying the active round's thinking). + 2. **Selector** — the message carries a `toolCall` part with name `compress` (only compress; other protected tools would need their own explicit config). + 3. **Size** — the message's total reasoning length (chars, summed across parts of that message, never across messages) **strictly exceeds** `threshold`. `0` drops any non-empty reasoning. + + Persisted history is never modified — the pass only rewrites the outgoing view, rebuilt fresh from the session log on every request. Pure, idempotent, fail-safe (any error leaves messages untouched). Merged field-wise (`drop`, `threshold` separately) across the three levels of `compress.providers`. + + Fields: + - `drop` (`boolean`, default `true`) — master switch; `false` disables the pass (kill-switch). + - `threshold` (`number`, chars, default `2048`) — single-thinking size gate. + + Providers whose thinking items are opaque and must round-trip unmodified (e.g. OpenAI encrypted reasoning) can opt out per-provider:\n ```json + { "compress": { "providers": { "openai": { "reasoning": { "drop": false } } } } } + ``` + ### `compress.providers` — per-provider & per-model overrides - **Type:** object — a map of provider name → `{ ..., models: { modelId → } }` diff --git a/CONFIGURATION.zh-CN.md b/CONFIGURATION.zh-CN.md index 412ddbf..d095617 100644 --- a/CONFIGURATION.zh-CN.md +++ b/CONFIGURATION.zh-CN.md @@ -48,7 +48,8 @@ "compress": { "maxContextLimit": "75%", "emergencyThresholdPercent": "95%", - "nudgeGrowthTokens": 50000 + "nudgeGrowthTokens": 50000, + "reasoning": { "drop": true, "threshold": 2048 } } } ``` @@ -140,6 +141,7 @@ | `compress.maxContextLimit` | number \| string | `"75%"` | 🟢 ACTIVE | 触发强制压缩 nudge 的上下文阈值。 | | `compress.emergencyThresholdPercent` | number \| string | `"95%"` | 🟢 ACTIVE | 触发紧急截断的上下文阈值。 | | `compress.nudgeGrowthTokens` | number | `50000` | 🟢 ACTIVE | 软压缩 nudge 的 token 增长步长。 | +| `compress.reasoning` | object | `{ "drop": true, "threshold": 2048 }` | 🟢 ACTIVE | 请求时丢弃历史 `compress` 调用上的超大思考(不修改持久化历史)。 | **prompts 键** @@ -449,6 +451,27 @@ - **说明:** 控制**软**压缩 nudge 频率的 token 增长阈值。每当积累约这么多新可压缩内容时,触发一次软 nudge。值越低模型被 nudge 压缩的频率越高;值越高频率越低。此设置只控制*基于增长的* nudge——用量越过 `compress.maxContextLimit` 后,强制 nudge 接管,不受此设置影响。映射到内核设置 `nudge.growthFloor` 和 `nudge.growthCap`。 - **同轮重注入:** 同一用户轮内 nudge 至多注入一次,但上下文自上次注入后又增长满一个增长门槛(镜像内核防抖 cadence:`max(minGrowthFloor, minGrowthRatio × adaptiveGrowth)`,默认 22.5K token)时,会在同轮重新注入新提醒(issue #269:模型忽略 78% nudge 后,原来会一直沉默到 95% emergency 机械截断)。成功 compress 后增长基线重锚到新(更小)刻度,压缩后重新长回压力带不会被压缩前峰值压制。 +### `compress.reasoning` + +- **类型:** `object` —— `{ "drop": boolean, "threshold": number }` +- **默认值:** `{ "drop": true, "threshold": 2048 }` +- **状态:** 🟢 ACTIVE +- **说明:** 控制从历史 `compress` 工具调用中丢弃超大 reasoning(思考)部分,与 [opencode-acp #377](https://github.com/ranxianglei/opencode-acp/pull/377) 完全对齐。`compress` 调用被硬排除在压缩之外(其工具结果是块摘要的锚点),其思考会随每次请求原样重发,形成无法回收的上下文底座。一个请求时 pass 只在**全部**门控满足时移除 `thinking` 部分: + 1. **已闭合轮次** —— 消息严格位于最后一条真实用户消息之前;活跃轮永不触碰(部分 provider 要求回放活跃轮思考)。 + 2. **选择器** —— 消息携带 `toolCall` 部分且 name 为 `compress`(仅 compress;其他保护工具如需支持应单独显式配置)。 + 3. **大小** —— 该消息 reasoning 总长(字符数,仅对该消息各部分求和,不跨消息累计)**严格大于** `threshold` 才丢弃;`0` 表示丢弃任何非空 reasoning。 + + 持久化历史从不被修改——pass 只改写出口视图,每次请求从会话日志全新重建。纯函数、幂等、fail-safe(任何错误原样返回)。在 `compress.providers` 三级间逐字段合并(`drop`、`threshold` 各自独立)。 + + 字段: + - `drop`(`boolean`,默认 `true`)—— 总开关;`false` 完全禁用(kill-switch)。 + - `threshold`(`number`,字符数,默认 `2048`)—— 单条思考大小门。 + + 思考项不透明且必须原样回传的 provider(如 OpenAI 加密 reasoning)可按 provider 退出: + ```json + { "compress": { "providers": { "openai": { "reasoning": { "drop": false } } } } } + ``` + ### `compress.providers` —— 按 provider / 按 model 覆盖 - **类型:** object —— provider 名 → `{ ..., models: { modelId → } }` 的映射 diff --git a/src/config.ts b/src/config.ts index 09aa42f..acae09a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,4 +1,5 @@ import { defaultConfig, type Config, type Prompts } from "acp-kernel"; +import type { CompressReasoningConfig } from "./reasoning-drop.js"; import type { ThrottleRetryConfig } from "./throttle-retry.js"; import { logWarn } from "./log.js"; @@ -129,6 +130,10 @@ export interface CompressSettings { * window and would suppress every nudge. Maps to kernel * nudge.minPressureBenefitTokens. */ minPressureBenefitTokens?: number; + /** [#336] Drop oversized reasoning (thinking) from historical `compress` + * tool calls — see CompressReasoningConfig in src/reasoning-drop.ts. + * Merged field-wise (drop, threshold) across the three levels. */ + reasoning?: CompressReasoningConfig; } /** Per-provider compression overrides. Carries the same tuning fields as the @@ -358,6 +363,10 @@ export function mergeCompress( emergencyThresholdPercent: model?.emergencyThresholdPercent ?? provider?.emergencyThresholdPercent ?? global?.emergencyThresholdPercent, nudgeGrowthTokens: model?.nudgeGrowthTokens ?? provider?.nudgeGrowthTokens ?? global?.nudgeGrowthTokens, minPressureBenefitTokens: model?.minPressureBenefitTokens ?? provider?.minPressureBenefitTokens ?? global?.minPressureBenefitTokens, + reasoning: { + drop: model?.reasoning?.drop ?? provider?.reasoning?.drop ?? global?.reasoning?.drop, + threshold: model?.reasoning?.threshold ?? provider?.reasoning?.threshold ?? global?.reasoning?.threshold, + }, }; } diff --git a/src/index.ts b/src/index.ts index 6f6680c..b83228d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,6 +19,7 @@ import { makeStatusTool } from "./status-tool.js"; import { makeDelegateTool, makeDelegateWaitTool, makeDelegateCancelTool, runningRunsSnapshot, resetDelegateUsage, setDelegateDisplayUsage, setDelegatePolicy, setDelegateDefaults, setDelegateNotifyIfRead, markDelegateResultRead, markDelegateRunReadByCommand } from "./delegate-tool.js"; import { makeCommands } from "./commands.js"; import { coreOutToAgentMessages, extractText } from "./messages.js"; +import { dropCompressReasoning } from "./reasoning-drop.js"; import { buildAcpSystemPrompt, ACP_DELEGATE_PROMPT } from "./system-prompt.js"; import { delegateStatusWidget } from "./fleet-widget.js"; import { openFleetInspector } from "./fleet-inspector.js"; @@ -395,7 +396,20 @@ function wireContextTransform(pi: ExtensionAPI, runtime: AcpRuntime, standDownIf }); const originalById = collectOriginals(entries); - const rebuilt = coreOutToAgentMessages(turn.messages, originalById); + let rebuilt = coreOutToAgentMessages(turn.messages, originalById); + // [#336] Request-time reasoning drop, aligned with opencode-acp #377: + // compress calls are hard-exempt from compression, so their thinking + // rides along every request as an unreclaimable floor. Applied BEFORE the + // nudge push so a synthetic user-role nudge can never become the "last + // genuine user message" boundary and extend the closed zone over the + // active round. Persisted history is never modified — this only rewrites + // the outgoing view, rebuilt fresh from entries on every event. + const reasoningDrop = runtime.reasoningDropFor(ctx); + const droppedThinking = dropCompressReasoning(rebuilt, reasoningDrop); + if (droppedThinking !== rebuilt) { + debug.event("reasoning-drop", { sid, dropped: droppedThinking.length, drop: reasoningDrop.drop, threshold: reasoningDrop.threshold }); + } + rebuilt = droppedThinking; const debugOn = debug.enabled; const turnKey = lastUserMessageId(entries) ?? sid; diff --git a/src/reasoning-drop.ts b/src/reasoning-drop.ts new file mode 100644 index 0000000..48503ed --- /dev/null +++ b/src/reasoning-drop.ts @@ -0,0 +1,100 @@ +import type { SessionMessageEntry } from "@earendil-works/pi-coding-agent"; +import { logWarn } from "./log.js"; + +type AgentMessage = SessionMessageEntry["message"]; + +/** [#336] Config for dropping oversized reasoning (thinking) parts from + * historical `compress` tool calls — exact alignment with opencode-acp #377. + * `compress` calls are hard-exempt from compression (their tool results are + * the anchors that keep block summaries addressable), so their thinking + * rides along every request as an unreclaimable context floor. This pass + * removes `thinking` parts at request time (persisted history is never + * modified) from closed-turn compress messages whose total reasoning length + * exceeds `threshold`. The active round (from the last genuine user message + * onward) is never touched. */ +export interface CompressReasoningConfig { + /** Master switch. Default: true. `drop: false` disables the pass entirely + * (kill-switch; also the recipe for providers whose thinking items are + * opaque and must round-trip unmodified, e.g. set it per-provider under + * `compress.providers.`). */ + drop?: boolean; + /** Single-thinking size gate (chars): a closed-turn compress message's + * total reasoning length (summed across parts of that message) must + * STRICTLY EXCEED this to be dropped. Small thinkings are kept; lengths + * are NOT accumulated across messages. Default: 2048. `0` drops any + * non-empty reasoning (only zero-length reasoning survives). */ + threshold?: number; +} + +export const DEFAULT_COMPRESS_REASONING: Required = { drop: true, threshold: 2048 }; + +export function resolveReasoningDrop(cfg?: CompressReasoningConfig): Required { + let threshold = DEFAULT_COMPRESS_REASONING.threshold; + if (cfg?.threshold !== undefined) { + const t = cfg.threshold; + if (typeof t === "number" && Number.isFinite(t) && t >= 0) { + threshold = Math.floor(t); + } else { + logWarn("config", { event: "compress-reasoning-invalid", field: "threshold", value: t, fallback: DEFAULT_COMPRESS_REASONING.threshold }); + } + } + return { drop: cfg?.drop !== false, threshold }; +} + +function isThinking(part: unknown): part is { type: "thinking"; thinking: string } { + const p = part as { type?: string; thinking?: unknown }; + return p?.type === "thinking" && typeof p.thinking === "string"; +} + +function hasCompressCall(content: unknown): boolean { + if (!Array.isArray(content)) return false; + return content.some((p) => { + const b = p as { type?: string; name?: string }; + return b?.type === "toolCall" && b.name === "compress"; + }); +} + +function reasoningLength(content: unknown): number { + if (!Array.isArray(content)) return 0; + return content.reduce((n, p) => (isThinking(p) ? n + p.thinking.length : n), 0); +} + +/** Request-time pass aligned with opencode-acp #377: remove `thinking` parts + * from a message only when ALL gates hold — + * 1. closed turn: the message is strictly before the last genuine user + * message (pi gives tool results their own `toolResult` role, so every + * `user` message is genuine); + * 2. selector: the message carries a `toolCall` part with name "compress" + * (only compress; other protected tools would need their own explicit + * config); + * 3. size: the message's total reasoning length strictly exceeds + * `threshold` chars (summed across parts, never across messages). + * Pure: never mutates the input; idempotent; fail-safe (any error returns + * the input unchanged). */ +export function dropCompressReasoning(messages: AgentMessage[], cfg?: CompressReasoningConfig): AgentMessage[] { + const { drop, threshold } = resolveReasoningDrop(cfg); + if (!drop || messages.length === 0) return messages; + try { + let lastUser = -1; + for (let i = 0; i < messages.length; i++) { + if ((messages[i] as { role?: string }).role === "user") lastUser = i; + } + if (lastUser < 0) return messages; + let changed = false; + const out = messages.slice(); + for (let i = 0; i < lastUser; i++) { + const msg = messages[i] as { role?: string; content?: unknown }; + if (msg.role !== "assistant" || !Array.isArray(msg.content)) continue; + if (!hasCompressCall(msg.content)) continue; + if (reasoningLength(msg.content) <= threshold) continue; + out[i] = { + ...(msg as object), + content: (msg.content as unknown[]).filter((p) => !isThinking(p)), + } as AgentMessage; + changed = true; + } + return changed ? (out as AgentMessage[]) : messages; + } catch { + return messages; + } +} diff --git a/src/runtime.ts b/src/runtime.ts index cfb7bbd..f70bc24 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -8,7 +8,8 @@ import { type Config, type Prompts, } from "acp-kernel"; -import { resolveConfig, type AdapterConfig } from "./config.js"; +import { resolveCompress, resolveConfig, type AdapterConfig } from "./config.js"; +import { resolveReasoningDrop, type CompressReasoningConfig } from "./reasoning-drop.js"; import { entriesToCoreMessages, extractText, matchesStoredText, messageIdentity, messageRef } from "./messages.js"; import { SessionStateStore, type LiveRefOrigin } from "./state.js"; import { hasCompressHistory, rebuildStateFromLog } from "./state-rebuild.js"; @@ -92,6 +93,10 @@ export interface AcpRuntime { clearCompressRetryTracking(): void; liveContextLimit(ctx: ExtensionContext): number; configFor(ctx: ExtensionContext): Config; + /** [#336] Effective compress.reasoning drop settings for the active model + * (three-level merge + defaults). Feeds the request-time pass in the + * context transform. */ + reasoningDropFor(ctx: ExtensionContext): Required; /** Re-read ~/./acp.json + //acp.json and re-derive the adapter * config when the contents change. Cheap no-op when unchanged. Called at * session_start and on every context event so config edits apply live. */ @@ -381,6 +386,11 @@ export function createRuntime(adapter: AdapterConfig): AcpRuntime { return resolveConfig(adapterRef, liveContextLimit(ctx), m?.provider, m?.id); } + function reasoningDropFor(ctx: ExtensionContext): Required { + const m = ctx.model as { provider?: string; id?: string } | undefined; + return resolveReasoningDrop(resolveCompress(adapterRef.compress, m?.provider, m?.id).reasoning); + } + async function reloadConfig(cwd: string): Promise { let user; try { @@ -457,4 +467,4 @@ export function createRuntime(adapter: AdapterConfig): AcpRuntime { let refused = false; let refusalMessage: string | null = null; - return { core, store, get refused() { return refused; }, set refused(v: boolean) { refused = v; }, get refusalMessage() { return refusalMessage; }, set refusalMessage(v: string | null) { refusalMessage = v; }, get adapter() { return adapterRef; }, setAdapter: (a) => { adapterRef = a; }, get prompts() { return promptsRef; }, setPrompts: (p) => { promptsRef = p; }, markNudgeShown: (k, t) => { nudgeShownTurns.add(k); if (t !== undefined) nudgeShownTokens.set(k, t); }, nudgeShownFor: (k) => nudgeShownTurns.has(k), nudgeShownTokensFor: (k) => nudgeShownTokens.get(k), clearNudgeTracking: () => { nudgeShownTurns.clear(); nudgeShownTokens.clear(); }, clearNudgeTokenStamps: () => nudgeShownTokens.clear(), noteCompressOutcomes, compressRetryCappedFor, clearCompressRetryTracking, liveContextLimit, configFor, reloadConfig, stateFor, save, acquireLock, overflowFor, overflowDrop, noteDeadCompress, clearDeadCompress, throttleFor, throttleDrop , noteTokenScale, dropTokenScale };} + return { core, store, get refused() { return refused; }, set refused(v: boolean) { refused = v; }, get refusalMessage() { return refusalMessage; }, set refusalMessage(v: string | null) { refusalMessage = v; }, get adapter() { return adapterRef; }, setAdapter: (a) => { adapterRef = a; }, get prompts() { return promptsRef; }, setPrompts: (p) => { promptsRef = p; }, markNudgeShown: (k, t) => { nudgeShownTurns.add(k); if (t !== undefined) nudgeShownTokens.set(k, t); }, nudgeShownFor: (k) => nudgeShownTurns.has(k), nudgeShownTokensFor: (k) => nudgeShownTokens.get(k), clearNudgeTracking: () => { nudgeShownTurns.clear(); nudgeShownTokens.clear(); }, clearNudgeTokenStamps: () => nudgeShownTokens.clear(), noteCompressOutcomes, compressRetryCappedFor, clearCompressRetryTracking, liveContextLimit, configFor, reasoningDropFor, reloadConfig, stateFor, save, acquireLock, overflowFor, overflowDrop, noteDeadCompress, clearDeadCompress, throttleFor, throttleDrop , noteTokenScale, dropTokenScale };} diff --git a/tests/reasoning-drop.test.ts b/tests/reasoning-drop.test.ts new file mode 100644 index 0000000..eb1d523 --- /dev/null +++ b/tests/reasoning-drop.test.ts @@ -0,0 +1,145 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import type { SessionMessageEntry } from "@earendil-works/pi-coding-agent"; +import { DEFAULT_COMPRESS_REASONING, dropCompressReasoning, resolveReasoningDrop } from "../src/reasoning-drop.js"; +import { resolveCompress } from "../src/config.js"; + +type AgentMessage = SessionMessageEntry["message"]; + +function user(text: string): AgentMessage { + return { role: "user", content: text, timestamp: 0 } as unknown as AgentMessage; +} + +function assistant(parts: unknown[]): AgentMessage { + return { role: "assistant", content: parts, timestamp: 0 } as unknown as AgentMessage; +} + +function thinking(len: number, extra: Record = {}): { type: "thinking"; thinking: string } & Record { + return { type: "thinking", thinking: "x".repeat(len), ...extra }; +} + +function compressCall(id = "c1"): { type: "toolCall"; id: string; name: string; arguments: Record } { + return { type: "toolCall", id, name: "compress", arguments: {} }; +} + +function otherCall(name = "bash"): { type: "toolCall"; id: string; name: string; arguments: Record } { + return { type: "toolCall", id: "t1", name, arguments: {} }; +} + +test("defaults: drop=true, threshold=2048; drop:false disables", () => { + assert.deepEqual(DEFAULT_COMPRESS_REASONING, { drop: true, threshold: 2048 }); + assert.deepEqual(resolveReasoningDrop(undefined), { drop: true, threshold: 2048 }); + assert.deepEqual(resolveReasoningDrop({}), { drop: true, threshold: 2048 }); + assert.deepEqual(resolveReasoningDrop({ drop: false }), { drop: false, threshold: 2048 }); + assert.deepEqual(resolveReasoningDrop({ threshold: 0 }), { drop: true, threshold: 0 }); +}); + +test("invalid threshold falls back to default, invalid drop is truthy", () => { + assert.equal(resolveReasoningDrop({ threshold: -1 }).threshold, 2048); + assert.equal(resolveReasoningDrop({ threshold: Number.NaN }).threshold, 2048); + assert.equal(resolveReasoningDrop({ threshold: 8192.9 }).threshold, 8192); + assert.equal(resolveReasoningDrop({ drop: "yes" as unknown as boolean }).drop, true); +}); + +test("gate: closed turn — messages from the last genuine user message onward are untouched", () => { + const big = thinking(4096); + const msgs = [ + assistant([{ type: "text", text: "old" }, thinking(4096), compressCall()]), + user("go"), + assistant([{ type: "text", text: "active" }, thinking(4096), compressCall()]), + ]; + const out = dropCompressReasoning(msgs, { drop: true, threshold: 0 }); + assert.equal((out[0]!.content as unknown[]).includes(big), false); + assert.deepEqual(out[2]!.content, msgs[2]!.content); +}); + +test("gate: selector — only messages carrying a compress toolCall part are touched", () => { + const think = thinking(4096); + const textOnly = assistant([{ type: "text", text: "hi" }, thinking(4096)]); + const otherTool = assistant([{ type: "text", text: "hi" }, thinking(4096), otherCall()]); + const msgs = [textOnly, otherTool, user("go")]; + const out = dropCompressReasoning(msgs, { drop: true, threshold: 0 }); + assert.deepEqual(out[0]!.content, textOnly.content); + assert.deepEqual(out[1]!.content, otherTool.content); + assert.equal(thinking(1).type, "thinking"); +}); + +test("gate: size — strictly exceeds threshold, summed across parts of the same message only", () => { + const at = assistant([{ type: "text", text: "hi" }, thinking(1024), thinking(1024), compressCall()]); + const above = assistant([{ type: "text", text: "hi" }, thinking(1025), thinking(1025), compressCall()]); + const splitKept = [assistant([{ type: "text", text: "hi" }, thinking(1500), compressCall()]), assistant([{ type: "text", text: "hi" }, thinking(1500), compressCall()]), user("go")]; + const out = dropCompressReasoning([at, above, user("go")], { drop: true, threshold: 2048 }); + assert.deepEqual(out[0]!.content, at.content); // 2048 == threshold → kept + assert.equal((out[1]!.content as unknown[]).some((p) => p === (above.content as unknown[])[1]), false); // 2050 > 2048 → dropped + const out2 = dropCompressReasoning(splitKept, { drop: true, threshold: 2048 }); + assert.deepEqual(out2[0]!.content, splitKept[0]!.content); // lengths not accumulated across messages + assert.deepEqual(out2[1]!.content, splitKept[1]!.content); +}); + +test("threshold 0 drops any non-empty reasoning; zero-length survives", () => { + const nonEmpty = assistant([{ type: "text", text: "hi" }, thinking(3), compressCall()]); + const empty = assistant([{ type: "text", text: "hi" }, thinking(0), compressCall()]); + const out = dropCompressReasoning([nonEmpty, empty, user("go")], { drop: true, threshold: 0 }); + assert.equal((out[0]!.content as unknown[]).length, 2); + assert.equal((out[1]!.content as unknown[]).length, 3); +}); + +test("purity and idempotence: input never mutated; second pass is a no-op", () => { + const original = assistant([{ type: "text", text: "hi" }, thinking(4096), compressCall()]); + const msgs = [original, user("go")]; + const out1 = dropCompressReasoning(msgs, { drop: true, threshold: 0 }); + assert.deepEqual(original.content, [{ type: "text", text: "hi" }, thinking(4096), compressCall()]); // input unmutated + assert.notEqual(out1[0], msgs[0]); // rewritten message is a new object + const out2 = dropCompressReasoning(out1, { drop: true, threshold: 0 }); + assert.equal(out2, out1); // idempotent +}); + +test("fail-safe: malformed messages return the input unchanged", () => { + const msgs = [ + { role: "assistant", content: null }, + { role: "assistant" }, + "garbage", + user("go"), + ] as unknown as AgentMessage[]; + assert.equal(dropCompressReasoning(msgs, { drop: true, threshold: 0 }), msgs); +}); + +test("drop:false is a full kill-switch", () => { + const msgs = [assistant([{ type: "text", text: "hi" }, thinking(99999), compressCall()]), user("go")]; + assert.equal(dropCompressReasoning(msgs, { drop: false }), msgs); +}); + +test("no user message at all → nothing touched (all open round)", () => { + const msgs = [assistant([{ type: "text", text: "hi" }, thinking(4096), compressCall()])]; + assert.equal(dropCompressReasoning(msgs, { drop: true, threshold: 0 }), msgs); +}); + +test("text and toolCall parts (incl. thoughtSignature) survive the drop", () => { + const call = { ...compressCall(), thoughtSignature: "sig" }; + const out = dropCompressReasoning( + [assistant([{ type: "text", text: "keep" }, thinking(4096), call]), user("go")], + { drop: true, threshold: 0 }, + ); + const content = out[0]!.content as unknown[]; + assert.deepEqual(content, [{ type: "text", text: "keep" }, call]); +}); + +test("three-level merge: model > provider > global, field-wise", () => { + const merged = resolveCompress( + { + reasoning: { drop: true, threshold: 2048 }, + providers: { + openai: { reasoning: { drop: false }, models: { "gpt-5": { reasoning: { threshold: 0 } } } }, + }, + }, + "openai", + "gpt-5", + ); + assert.deepEqual(merged.reasoning, { drop: false, threshold: 0 }); + const provOnly = resolveCompress( + { reasoning: { threshold: 100 }, providers: { openai: { reasoning: { drop: false } } } }, + "openai", + undefined, + ); + assert.deepEqual(provOnly.reasoning, { drop: false, threshold: 100 }); // provider only overrides drop +});