diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index 2db7dc48b0..ed30207089 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -146,7 +146,11 @@ Settings are organized into categories. Most settings should be placed within th | `model.maxWallTimeSeconds` | number | Wall-clock budget for headless / unattended runs, in seconds. `-1` means unlimited. Overridable per-invocation via `--max-wall-time`, which requires a positive duration (`90`, `30s`, `5m`, `1h`, `1.5h`); the minimum is 1 second — sub-second values (`500ms`, `0.5`) are rejected as typos. Omit the flag to fall back to this setting. Aborts with exit code 55 when exceeded. | `-1` | | `model.maxToolCalls` | number | Cumulative tool-call budget for a run (counts every executed tool, success or failure; `structured_output` under `--json-schema` is exempt). `-1` means unlimited; `0` means "no tool calls allowed". Capped at 1,000,000 to catch typos. Overridable via `--max-tool-calls`. Aborts with exit code 55 when exceeded. | `-1` | | `model.generationConfig` | object | Advanced overrides passed to the underlying content generator. Supports request controls such as `timeout`, `maxRetries`, `enableCacheControl`, `splitToolMedia` (set `true` for strict OpenAI-compatible servers like LM Studio that reject non-text content on `role: "tool"` messages — splits media into a follow-up user message), `contextWindowSize` (override model's context window size), `modalities` (override auto-detected input modalities), `customHeaders` (custom HTTP headers for API requests), and `extra_body` (additional body parameters for OpenAI-compatible API requests only), along with fine-tuning knobs under `samplingParams` (for example `temperature`, `top_p`, `max_tokens`). Leave unset to rely on provider defaults. | `undefined` | -| `model.chatCompression.contextPercentageThreshold` | number | **REMOVED.** Auto-compaction now uses a three-tier threshold ladder (warn / auto / hard) computed internally from the model's context window via the `computeThresholds()` function — no longer user-configurable. Setting this field in `settings.json` is silently ignored, and a one-line deprecation warning is emitted to stderr at startup. There is currently no replacement for "disable compression entirely" — reactive overflow recovery remains the safety net at the API layer if compression itself fails. (See PR #4345 / `docs/design/auto-compaction-threshold-redesign.md` for the redesign rationale.) | `N/A` | +| `model.chatCompression.contextPercentageThreshold` | number | **REMOVED.** Auto-compaction now uses a three-tier threshold ladder (warn / auto / hard) computed internally from the model's context window via the `computeThresholds()` function — no longer user-configurable. Setting this field in `settings.json` is silently ignored (no startup warning). There is currently no replacement for "disable compression entirely" — reactive overflow recovery remains the safety net at the API layer if compression itself fails. (See PR #4345 / `docs/design/auto-compaction-threshold-redesign.md` for the redesign rationale.) | `N/A` | +| `model.chatCompression.maxRecentFilesToRetain` | number | Number of most-recently-touched files whose current content is restored (embedded if small, otherwise referenced by path) into history after auto-compaction. `0` restores none. Env override: `QWEN_COMPACT_MAX_RECENT_FILES`. | `5` | +| `model.chatCompression.maxRecentImagesToRetain` | number | Number of most-recent images (tool screenshots / user pastes) restored into history after auto-compaction. `0` restores none. Env override: `QWEN_COMPACT_MAX_RECENT_IMAGES`. | `3` | +| `model.chatCompression.enableScreenshotTrigger` | boolean | When `true`, auto-compaction also fires once the number of tool-returned images accumulated in history reaches `screenshotTriggerThreshold`, independent of token usage — aimed at computer-use sessions where frequent screenshots dilute model attention. Counts only images returned inside tool results, not user-pasted images. Env override: `QWEN_COMPACT_SCREENSHOT_TRIGGER` (`1`/`true`/`0`/`false`). | `true` | +| `model.chatCompression.screenshotTriggerThreshold` | number | Tool-returned image count at or above which the screenshot trigger fires (only when `enableScreenshotTrigger`). Compaction resets the count — surviving images are re-embedded as top-level parts, which the trigger doesn't count — so it won't immediately re-fire. Env override: `QWEN_COMPACT_SCREENSHOT_THRESHOLD`. | `50` | | `model.skipNextSpeakerCheck` | boolean | Skip the next speaker check. | `false` | | `model.skipLoopDetection` | boolean | Disables streaming loop detection checks. Defaults to `true` (loop detection is skipped) to avoid false positives interrupting legitimate workflows. Set to `false` to re-enable streaming loop detection — useful as a guardrail in headless / non-interactive runs where stuck repetition can otherwise waste budget. | `true` | | `model.skipStartupContext` | boolean | Skips sending the startup workspace context (environment summary and acknowledgement) at the beginning of each session. Enable this if you prefer to provide context manually or want to save tokens on startup. | `false` | diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index ea610b1583..46183d2a9b 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -6,11 +6,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import type { Mock } from 'vitest'; -import type { - ChatCompressionSettings, - ConfigParameters, - SandboxConfig, -} from './config.js'; +import type { ConfigParameters, SandboxConfig } from './config.js'; import { Config, ApprovalMode, @@ -3368,55 +3364,4 @@ describe('Model Switching and Config Updates', () => { ); }); }); - - describe('chatCompression.contextPercentageThreshold deprecation', () => { - // The proportional-threshold knob `contextPercentageThreshold` was - // removed in the auto-compaction threshold redesign (Task 8) — the - // value is now derived from `computeThresholds(...)` in the - // ChatCompressionService and is no longer user-tunable. Existing - // settings.json files that still set the field should keep working - // but get a one-time stderr warning so users know to remove it. - let warnSpy: ReturnType; - - beforeEach(() => { - warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - }); - - afterEach(() => { - warnSpy.mockRestore(); - }); - - it('logs a stderr warning when the deprecated field is set', () => { - new Config({ - ...baseParams, - chatCompression: { - contextPercentageThreshold: 0.5, - } as ChatCompressionSettings, - }); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining( - 'chatCompression.contextPercentageThreshold has been removed', - ), - ); - }); - - it('does not warn when chatCompression is absent', () => { - new Config({ ...baseParams }); - const warnCalls = warnSpy.mock.calls.map((c) => String(c[0])); - expect( - warnCalls.some((m) => m.includes('contextPercentageThreshold')), - ).toBe(false); - }); - - it('does not warn when chatCompression is set without the deprecated field', () => { - new Config({ - ...baseParams, - chatCompression: { imageTokenEstimate: 1600 }, - }); - const warnCalls = warnSpy.mock.calls.map((c) => String(c[0])); - expect( - warnCalls.some((m) => m.includes('contextPercentageThreshold')), - ).toBe(false); - }); - }); }); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index d99b0e544b..0fab26ed7a 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -270,12 +270,39 @@ export interface BugCommandSettings { export interface ChatCompressionSettings { /** * Estimated tokens for a single inline image / document part when - * apportioning chars across history in `findCompressSplitPoint`. + * apportioning chars across history during compression size estimation. * Also used as the placeholder budget when stripping inline media * out of the side-query compaction prompt. Default 1600. * Env override: `QWEN_IMAGE_TOKEN_ESTIMATE`. */ imageTokenEstimate?: number; + /** + * Number of most-recently-touched files whose current content is + * restored (embedded or referenced) after auto-compaction. Default 5. + * Env override: `QWEN_COMPACT_MAX_RECENT_FILES`. + */ + maxRecentFilesToRetain?: number; + /** + * Number of most-recent images (tool screenshots / user pastes) + * restored after auto-compaction. Default 3. + * Env override: `QWEN_COMPACT_MAX_RECENT_IMAGES`. + */ + maxRecentImagesToRetain?: number; + /** + * When true, auto-compaction also fires once the number of + * tool-returned images accumulated in history reaches + * `screenshotTriggerThreshold`, independent of token usage. Aimed at + * computer-use sessions where frequent screenshots dilute model + * attention without necessarily exceeding the token budget. Default true. + * Env override: `QWEN_COMPACT_SCREENSHOT_TRIGGER` (`1`/`true`/`0`/`false`). + */ + enableScreenshotTrigger?: boolean; + /** + * Tool-returned image count at or above which the screenshot trigger + * fires (only when `enableScreenshotTrigger`). Default 50. + * Env override: `QWEN_COMPACT_SCREENSHOT_THRESHOLD`. + */ + screenshotTriggerThreshold?: number; } /** @@ -1171,24 +1198,6 @@ export class Config { this.loadMemoryFromIncludeDirectories = params.loadMemoryFromIncludeDirectories ?? false; this.importFormat = params.importFormat ?? 'tree'; - // Auto-compaction threshold moved to built-in constants (computeThresholds - // in chatCompressionService.ts). The old `contextPercentageThreshold` - // field is deprecated; if present in user settings, emit a one-time - // warning and ignore the value. - if ( - params.chatCompression && - typeof (params.chatCompression as Record)[ - 'contextPercentageThreshold' - ] !== 'undefined' - ) { - // eslint-disable-next-line no-console - console.warn( - '[qwen-code] chatCompression.contextPercentageThreshold has been removed ' + - 'and is now controlled by built-in thresholds. Setting will be ignored. ' + - 'Remove this key from your settings.json to silence this warning; ' + - 'see docs/users/configuration/settings.md for current compaction behavior.', - ); - } this.chatCompression = params.chatCompression; this.interactive = params.interactive ?? false; this.trustedFolder = params.trustedFolder; diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index 51d29b6ff3..bd1b43e311 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -19,7 +19,6 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { Content, GenerateContentResponse, Part } from '@google/genai'; import { GeminiClient, SendMessageType } from './client.js'; -import { findCompressSplitPoint } from '../services/chatCompressionService.js'; import { getRecentGitStatus } from '../utils/gitUtils.js'; import { AuthType, @@ -265,84 +264,6 @@ function getLastTurnRequestText(): string { return JSON.stringify(request ?? ''); } -describe('findCompressSplitPoint', () => { - it('should throw an error for non-positive numbers', () => { - expect(() => findCompressSplitPoint([], 0)).toThrow( - 'Fraction must be between 0 and 1', - ); - }); - - it('should throw an error for a fraction greater than or equal to 1', () => { - expect(() => findCompressSplitPoint([], 1)).toThrow( - 'Fraction must be between 0 and 1', - ); - }); - - it('should handle an empty history', () => { - expect(findCompressSplitPoint([], 0.5)).toBe(0); - }); - - it('should handle a fraction in the middle', () => { - const history: Content[] = [ - { role: 'user', parts: [{ text: 'This is the first message.' }] }, // JSON length: 66 (19%) - { role: 'model', parts: [{ text: 'This is the second message.' }] }, // JSON length: 68 (40%) - { role: 'user', parts: [{ text: 'This is the third message.' }] }, // JSON length: 66 (60%) - { role: 'model', parts: [{ text: 'This is the fourth message.' }] }, // JSON length: 68 (80%) - { role: 'user', parts: [{ text: 'This is the fifth message.' }] }, // JSON length: 65 (100%) - ]; - expect(findCompressSplitPoint(history, 0.5)).toBe(4); - }); - - it('should handle a fraction of last index', () => { - const history: Content[] = [ - { role: 'user', parts: [{ text: 'This is the first message.' }] }, // JSON length: 66 (19%) - { role: 'model', parts: [{ text: 'This is the second message.' }] }, // JSON length: 68 (40%) - { role: 'user', parts: [{ text: 'This is the third message.' }] }, // JSON length: 66 (60%) - { role: 'model', parts: [{ text: 'This is the fourth message.' }] }, // JSON length: 68 (80%) - { role: 'user', parts: [{ text: 'This is the fifth message.' }] }, // JSON length: 65 (100%) - ]; - expect(findCompressSplitPoint(history, 0.9)).toBe(4); - }); - - it('should handle a fraction of after last index', () => { - const history: Content[] = [ - { role: 'user', parts: [{ text: 'This is the first message.' }] }, // JSON length: 66 (24%%) - { role: 'model', parts: [{ text: 'This is the second message.' }] }, // JSON length: 68 (50%) - { role: 'user', parts: [{ text: 'This is the third message.' }] }, // JSON length: 66 (74%) - { role: 'model', parts: [{ text: 'This is the fourth message.' }] }, // JSON length: 68 (100%) - ]; - expect(findCompressSplitPoint(history, 0.8)).toBe(4); - }); - - it('compresses everything before the trailing in-flight functionCall', () => { - const history: Content[] = [ - { role: 'user', parts: [{ text: 'This is the first message.' }] }, - { role: 'model', parts: [{ text: 'This is the second message.' }] }, - { role: 'user', parts: [{ text: 'This is the third message.' }] }, - { role: 'model', parts: [{ functionCall: {} }] }, - ]; - // Trailing m+fc is in-flight; the in-flight fallback compresses - // everything except the trailing fc (no preceding pair to retain). - expect(findCompressSplitPoint(history, 0.99)).toBe(3); - }); - - it('should handle a history with only one item', () => { - const historyWithEmptyParts: Content[] = [ - { role: 'user', parts: [{ text: 'Message 1' }] }, - ]; - expect(findCompressSplitPoint(historyWithEmptyParts, 0.5)).toBe(0); - }); - - it('should handle history with weird parts', () => { - const historyWithEmptyParts: Content[] = [ - { role: 'user', parts: [{ text: 'Message 1' }] }, - { role: 'model', parts: [{ fileData: { fileUri: 'derp' } }] }, - { role: 'user', parts: [{ text: 'Message 2' }] }, - ]; - expect(findCompressSplitPoint(historyWithEmptyParts, 0.5)).toBe(2); - }); -}); - describe('Gemini Client (client.ts)', () => { let mockContentGenerator: ContentGenerator; let mockConfig: Config; diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index a878ba3d78..15146b45ac 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -47,7 +47,6 @@ import { } from './turn.js'; // Services -import { COMPRESSION_PRESERVE_THRESHOLD } from '../services/chatCompressionService.js'; import { LoopDetectionService } from '../services/loopDetectionService.js'; import { CommitAttributionService } from '../services/commitAttribution.js'; @@ -2182,7 +2181,3 @@ export class GeminiClient { return info; } } - -export const TEST_ONLY = { - COMPRESSION_PRESERVE_THRESHOLD, -}; diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index 2b55cacc11..dcea200f1b 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -123,6 +123,7 @@ describe('GeminiChat', async () => { getModel: vi.fn().mockReturnValue('gemini-pro'), setModel: vi.fn(), getProjectRoot: vi.fn().mockReturnValue('/test/project/root'), + getTargetDir: vi.fn().mockReturnValue('/test/project/root'), getCliVersion: vi.fn().mockReturnValue('1.0.0'), storage: { getProjectTempDir: vi.fn().mockReturnValue('/test/temp'), diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 759a74b433..030f6edcc8 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -1540,12 +1540,15 @@ export class GeminiChat { pendingUserMessage: userContent, precomputedEffectiveTokens: effectiveTokens, // Hard-rescue is force=true to bypass the cheap-gate breaker - // but it's an AUTOMATIC trigger. Explicit trigger='auto' tells - // the service to skip the manual-only orphan-strip that would - // otherwise drop the active funcCall whose matching - // funcResponse is sitting in `pendingUserMessage` waiting to - // be pushed. Without this, hard-rescue mid tool-use loop - // corrupts the next API request's tool-call/response pairing. + // but it remains a semantically AUTOMATIC trigger. Tag the + // compactTrigger explicitly as 'auto' so the PostCompact + // hook event fires with the correct trigger category (the + // default `force=true → 'manual'` mapping would otherwise + // misclassify it). The compress() service preserves a + // trailing model+functionCall via + // composePostCompactHistory's `trailingFunctionCallContent` + // handling on its own, so the API request's tool-call / + // response pairing stays intact regardless of trigger value. trigger: shouldForceFromHard ? 'auto' : undefined, }, ); diff --git a/packages/core/src/core/prompts.test.ts b/packages/core/src/core/prompts.test.ts index e9b052b18a..cf5e620afa 100644 --- a/packages/core/src/core/prompts.test.ts +++ b/packages/core/src/core/prompts.test.ts @@ -12,6 +12,7 @@ import { getSubagentSystemReminder, getPlanModeSystemReminder, resolvePathFromEnv, + getCompressionPrompt, } from './prompts.js'; import { isGitRepository } from '../utils/gitUtils.js'; import fs from 'node:fs'; @@ -761,3 +762,46 @@ describe('New Applications workflow deferred to skill', () => { expect(prompt).toContain('## New Applications'); }); }); + +describe('getCompressionPrompt', () => { + it('uses the XML envelope with all 9 required section tags', () => { + const prompt = getCompressionPrompt(); + expect(prompt).toContain(''); + expect(prompt).toContain(''); + expect(prompt).toContain(''); + expect(prompt).toContain(''); + expect(prompt).toContain(''); + expect(prompt).toContain(''); + expect(prompt).toContain(''); + expect(prompt).toContain(''); + expect(prompt).toContain(''); + expect(prompt).toContain(''); + expect(prompt).toContain(''); + }); + + it('instructs the model to wrap reasoning in an block', () => { + const prompt = getCompressionPrompt(); + expect(prompt).toContain(''); + // Must signal that is stripped (so the model knows it is a + // drafting scratchpad, not part of the final summary). + expect(prompt).toMatch(/.*stripped|stripped.*/is); + }); + + it('asks for the section to be chronological and inclusive', () => { + const prompt = getCompressionPrompt(); + // The actual mandate text — verbatim-but-not-VERBATIM-policed. + expect(prompt).toMatch(/all user messages.*chronological/i); + expect(prompt).toContain('"ok"'); + expect(prompt).toContain('"continue"'); + }); + + it('does NOT include the resume trailer in the prompt body', () => { + // The trailer lives in postCompactAttachments.postProcessSummary, not in + // the prompt. Keeping it out of the prompt saves output tokens per + // compaction and prevents wording drift. + const prompt = getCompressionPrompt(); + expect(prompt).not.toMatch( + /resume.*directly|continue the conversation from where it left off/i, + ); + }); +}); diff --git a/packages/core/src/core/prompts.ts b/packages/core/src/core/prompts.ts index e5daacd5eb..ea1a8c5d4f 100644 --- a/packages/core/src/core/prompts.ts +++ b/packages/core/src/core/prompts.ts @@ -437,65 +437,64 @@ When you encounter an obstacle, do not use destructive actions as a shortcut to /** * Provides the system prompt for the history compression process. - * This prompt instructs the model to act as a specialized state manager, - * think in a scratchpad, and produce a structured XML summary. + * + * Asks the summary model to wrap its chain-of-thought in an `` + * block (stripped before the result enters history) and then emit a + * `` XML envelope with 9 sub-sections aligned to + * claude-code's compaction format: primary_request_and_intent, + * key_technical_concepts, files_and_code_sections, errors_and_fixes, + * problem_solving, all_user_messages, pending_tasks, current_work, + * next_step. + * + * The resume trailer ("do not acknowledge the summary, ..." etc.) is + * NOT in this prompt — it is appended once by `postProcessSummary` in + * `postCompactAttachments.ts` so the summary model does not re-generate + * it every compaction. */ export function getCompressionPrompt(): string { return ` -You are the component that summarizes internal chat history into a given structure. +You are the component that summarizes a conversation when its context window is about to overflow. The summary you produce will become the agent's ONLY memory of everything that happened before this point. The agent will resume its work based solely on this summary plus a small number of restored file / image attachments that follow. -When the conversation history grows too large, you will be invoked to distill the entire history into a concise, structured XML snapshot. This snapshot is CRITICAL, as it will become the agent's *only* memory of the past. The agent will resume its work based solely on this snapshot. All crucial details, plans, errors, and user directives MUST be preserved. +First, wrap your reasoning in an block. Inside it, walk through the conversation chronologically and identify, for each section: the user's explicit requests and intent, your approach to those requests, key decisions / technical concepts / code patterns, specific details (file names, code snippets, function signatures, file edits), errors and how they were fixed, and any specific user feedback — especially when the user told you to do something differently. The block is stripped before the summary reaches the next agent; it is purely a drafting scratchpad to improve the summary that follows. -First, you will think through the entire history in a private . Review the user's overall goal, the agent's actions, tool outputs, file modifications, and any unresolved questions. Identify every piece of information that is essential for future actions. - -After your reasoning is complete, generate the final XML object. Be incredibly dense with information. Omit any irrelevant conversational filler. - -The structure MUST be as follows: +Then produce the final summary as the EXACT XML structure below. Be dense. Omit conversational filler. - - - - + + + - - - - + + + - - - - + + + - - - - + + + - - - - + + + + + + + + + + + + + + + + + + + `.trim(); } diff --git a/packages/core/src/services/chatCompressionService.test.ts b/packages/core/src/services/chatCompressionService.test.ts index c73f08fcd7..b11a861b83 100644 --- a/packages/core/src/services/chatCompressionService.test.ts +++ b/packages/core/src/services/chatCompressionService.test.ts @@ -8,9 +8,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { ChatCompressionService, computeThresholds, - findCompressSplitPoint, MAX_CONSECUTIVE_FAILURES, - TOOL_ROUND_RETAIN_COUNT, } from './chatCompressionService.js'; import type { Content } from '@google/genai'; import { CompressionStatus } from '../core/turn.js'; @@ -21,365 +19,12 @@ import type { Config } from '../config/config.js'; import type { BaseLlmClient } from '../core/baseLlmClient.js'; import { PreCompactTrigger, PostCompactTrigger } from '../hooks/types.js'; import * as sideQueryModule from '../utils/sideQuery.js'; +import * as postCompactModule from './postCompactAttachments.js'; vi.mock('../telemetry/uiTelemetry.js'); vi.mock('../core/tokenLimits.js'); vi.mock('../telemetry/loggers.js'); -describe('findCompressSplitPoint', () => { - it('should throw an error for non-positive numbers', () => { - expect(() => findCompressSplitPoint([], 0)).toThrow( - 'Fraction must be between 0 and 1', - ); - }); - - it('should throw an error for a fraction greater than or equal to 1', () => { - expect(() => findCompressSplitPoint([], 1)).toThrow( - 'Fraction must be between 0 and 1', - ); - }); - - it('should handle an empty history', () => { - expect(findCompressSplitPoint([], 0.5)).toBe(0); - }); - - it('should handle a fraction in the middle', () => { - const history: Content[] = [ - { role: 'user', parts: [{ text: 'This is the first message.' }] }, // JSON length: 66 (19%) - { role: 'model', parts: [{ text: 'This is the second message.' }] }, // JSON length: 68 (40%) - { role: 'user', parts: [{ text: 'This is the third message.' }] }, // JSON length: 66 (60%) - { role: 'model', parts: [{ text: 'This is the fourth message.' }] }, // JSON length: 68 (80%) - { role: 'user', parts: [{ text: 'This is the fifth message.' }] }, // JSON length: 65 (100%) - ]; - expect(findCompressSplitPoint(history, 0.5)).toBe(4); - }); - - it('should handle a fraction of last index', () => { - const history: Content[] = [ - { role: 'user', parts: [{ text: 'This is the first message.' }] }, // JSON length: 66 (19%) - { role: 'model', parts: [{ text: 'This is the second message.' }] }, // JSON length: 68 (40%) - { role: 'user', parts: [{ text: 'This is the third message.' }] }, // JSON length: 66 (60%) - { role: 'model', parts: [{ text: 'This is the fourth message.' }] }, // JSON length: 68 (80%) - { role: 'user', parts: [{ text: 'This is the fifth message.' }] }, // JSON length: 65 (100%) - ]; - expect(findCompressSplitPoint(history, 0.9)).toBe(4); - }); - - it('should handle a fraction of after last index', () => { - const history: Content[] = [ - { role: 'user', parts: [{ text: 'This is the first message.' }] }, // JSON length: 66 (24%) - { role: 'model', parts: [{ text: 'This is the second message.' }] }, // JSON length: 68 (50%) - { role: 'user', parts: [{ text: 'This is the third message.' }] }, // JSON length: 66 (74%) - { role: 'model', parts: [{ text: 'This is the fourth message.' }] }, // JSON length: 68 (100%) - ]; - expect(findCompressSplitPoint(history, 0.8)).toBe(4); - }); - - it('compresses everything before the trailing in-flight functionCall', () => { - const history: Content[] = [ - { role: 'user', parts: [{ text: 'This is the first message.' }] }, - { role: 'model', parts: [{ text: 'This is the second message.' }] }, - { role: 'user', parts: [{ text: 'This is the third message.' }] }, - { role: 'model', parts: [{ functionCall: { name: 'foo', args: {} } }] }, - ]; - // Trailing m+fc is in-flight; no preceding (m+fc, u+fr) pair to retain, - // so the in-flight fallback compresses everything except the trailing fc. - // The kept slice starts with m+fc; callers bridge with a synthetic user. - expect(findCompressSplitPoint(history, 0.99)).toBe(3); - }); - - it('should handle a history with only one item', () => { - const historyWithEmptyParts: Content[] = [ - { role: 'user', parts: [{ text: 'Message 1' }] }, - ]; - expect(findCompressSplitPoint(historyWithEmptyParts, 0.5)).toBe(0); - }); - - it('should handle history with weird parts', () => { - const historyWithEmptyParts: Content[] = [ - { role: 'user', parts: [{ text: 'Message 1' }] }, - { - role: 'model', - parts: [{ fileData: { fileUri: 'derp', mimeType: 'text/plain' } }], - }, - { role: 'user', parts: [{ text: 'Message 2' }] }, - ]; - expect(findCompressSplitPoint(historyWithEmptyParts, 0.5)).toBe(2); - }); - - it('should compress everything when last message is a functionResponse', () => { - const history: Content[] = [ - { role: 'user', parts: [{ text: 'Fix this bug' }] }, - { - role: 'model', - parts: [{ functionCall: { name: 'readFile', args: {} } }], - }, - { - role: 'user', - parts: [ - { - functionResponse: { - name: 'readFile', - response: { result: 'file content' }, - }, - }, - ], - }, - { - role: 'model', - parts: [{ functionCall: { name: 'writeFile', args: {} } }], - }, - { - role: 'user', - parts: [ - { - functionResponse: { - name: 'writeFile', - response: { result: 'ok' }, - }, - }, - ], - }, - ]; - // Last message is functionResponse -> safe to compress everything - expect(findCompressSplitPoint(history, 0.7)).toBe(5); - }); - - it('retains last K complete tool rounds when no fresh user splits past target', () => { - const history: Content[] = [ - { role: 'user', parts: [{ text: 'Fix this' }] }, - { - role: 'model', - parts: [{ functionCall: { name: 'read1', args: {} } }], - }, - { - role: 'user', - parts: [ - { - functionResponse: { - name: 'read1', - response: { result: 'a'.repeat(1000) }, - }, - }, - ], - }, - { - role: 'model', - parts: [{ functionCall: { name: 'read2', args: {} } }], - }, - { - role: 'user', - parts: [ - { - functionResponse: { - name: 'read2', - response: { result: 'b'.repeat(1000) }, - }, - }, - ], - }, - { - role: 'model', - parts: [{ functionCall: { name: 'write1', args: {} } }], - }, - ]; - // 2 complete (m+fc, u+fr) pairs precede the trailing fc → retain both - // pairs + trailing fc = last 5 entries; compress index 0 (the task). - // Pre-refactor this returned 0 (NOOP); now it compresses-most. - expect(findCompressSplitPoint(history, 0.7)).toBe(history.length - 5); - }); - - it('prefers compress-most over lastSplitPoint when scan finds no clean split past target', () => { - const longContent = 'a'.repeat(10000); - const history: Content[] = [ - { role: 'user', parts: [{ text: 'Fix bug A' }] }, - { role: 'model', parts: [{ text: 'OK' }] }, - { role: 'user', parts: [{ text: 'Fix bug B' }] }, - { - role: 'model', - parts: [{ functionCall: { name: 'read1', args: {} } }], - }, - { - role: 'user', - parts: [ - { - functionResponse: { - name: 'read1', - response: { result: longContent }, - }, - }, - ], - }, - { - role: 'model', - parts: [{ functionCall: { name: 'read2', args: {} } }], - }, - { - role: 'user', - parts: [ - { - functionResponse: { - name: 'read2', - response: { result: longContent }, - }, - }, - ], - }, - { - role: 'model', - parts: [{ functionCall: { name: 'write1', args: {} } }], - }, - ]; - // 2 complete pairs before the trailing fc → retain both + trailing = 5 - // entries kept. Pre-refactor returned lastSplitPoint=2 (compress less). - expect(findCompressSplitPoint(history, 0.7)).toBe(history.length - 5); - }); - - it('compresses-most via in-flight fallback when scan never crosses the target', () => { - const history: Content[] = [ - { role: 'user', parts: [{ text: 'msg1' }] }, - { role: 'model', parts: [{ text: 'resp1' }] }, - { - role: 'user', - parts: [{ text: 'msg2 with some substantial content here' }], - }, - { - role: 'model', - parts: [{ functionCall: { name: 'tool1', args: {} } }], - }, - { - role: 'user', - parts: [ - { - functionResponse: { - name: 'tool1', - response: { result: 'short' }, - }, - }, - ], - }, - { role: 'user', parts: [{ text: 'msg3' }] }, - { role: 'model', parts: [{ text: 'resp3' }] }, - { role: 'user', parts: [{ text: 'msg4' }] }, - { - role: 'model', - parts: [{ functionCall: { name: 'tool2', args: {} } }], - }, - ]; - // The entry before the trailing fc is a fresh user (msg4), not a u+fr, - // so the pair walk stops with 0 pairs found → retain only the trailing - // fc, compress everything else. Pre-refactor returned lastSplitPoint=7. - expect(findCompressSplitPoint(history, 0.99)).toBe(history.length - 1); - }); - - it('honors precomputedCharCounts when provided', () => { - // Three messages of equal real length. If precomputedCharCounts - // claims the middle message is the heaviest, the split point should - // move past it. - const history: Content[] = [ - { role: 'user', parts: [{ text: 'a' }] }, - { role: 'model', parts: [{ text: 'b' }] }, - { role: 'user', parts: [{ text: 'c' }] }, - { role: 'model', parts: [{ text: 'd' }] }, - { role: 'user', parts: [{ text: 'e' }] }, - ]; - // Force the first three messages to dominate the budget so the - // splitter returns the index of the next user message (4). - const inflated = [1000, 1000, 1000, 1, 1]; - expect( - findCompressSplitPoint(history, 0.7, TOOL_ROUND_RETAIN_COUNT, inflated), - ).toBe(4); - // Same history with even weights yields the standard split. - const even = [1, 1, 1, 1, 1]; - expect( - findCompressSplitPoint(history, 0.7, TOOL_ROUND_RETAIN_COUNT, even), - ).toBe(4); - }); -}); - -describe('findCompressSplitPoint — in-flight fallback', () => { - const userTask = (text: string): Content => ({ - role: 'user', - parts: [{ text }], - }); - const modelText = (text: string): Content => ({ - role: 'model', - parts: [{ text }], - }); - const modelFc = (name: string): Content => ({ - role: 'model', - parts: [{ functionCall: { name, args: {} } }], - }); - const userFr = (name: string): Content => ({ - role: 'user', - parts: [{ functionResponse: { name, response: { result: 'x' } } }], - }); - - // Subagent-shaped history at compression check time: env bootstrap, task, - // alternating tool rounds, ending in a trailing in-flight model+fc whose - // functionResponse hasn't been pushed yet. The scan finds no clean split - // past the target fraction, so the in-flight fallback decides the index. - it('compresses everything except trailing fc + most recent retainCount pairs', () => { - const history = [ - userTask('env'), - modelText('env-ack'), - userTask('task'), - modelFc('a'), - userFr('a'), - modelFc('b'), - userFr('b'), - modelFc('c'), - userFr('c'), - modelFc('d'), - userFr('d'), - modelFc('trailing'), - ]; - // Default retainCount = 2 → keep last 5 (2 pairs + trailing). - expect(findCompressSplitPoint(history, 0.7)).toBe(history.length - 5); - }); - - it('retains all pairs when fewer than retainCount exist', () => { - const history = [ - userTask('env'), - modelText('env-ack'), - userTask('task'), - modelFc('a'), - userFr('a'), - modelFc('trailing'), - ]; - // Only 1 complete pair → keep last 3 (1 pair + trailing). - expect(findCompressSplitPoint(history, 0.7)).toBe(history.length - 3); - }); - - it('retains just the trailing fc when no complete pairs precede it', () => { - const history = [ - userTask('env'), - modelText('env-ack'), - userTask('task'), - modelFc('trailing'), - ]; - // No complete pairs → keep only the trailing fc. - expect(findCompressSplitPoint(history, 0.7)).toBe(history.length - 1); - }); - - it('respects an explicit retainCount override', () => { - const history = [ - userTask('env'), - modelText('env-ack'), - userTask('task'), - modelFc('a'), - userFr('a'), - modelFc('b'), - userFr('b'), - modelFc('c'), - userFr('c'), - modelFc('trailing'), - ]; - // Override retainCount to 1 → keep last 3 (1 pair + trailing). - expect(findCompressSplitPoint(history, 0.7, 1)).toBe(history.length - 3); - }); -}); - describe('ChatCompressionService', () => { let service: ChatCompressionService; let mockChat: GeminiChat; @@ -409,6 +54,7 @@ describe('ChatCompressionService', () => { warn: vi.fn(), debug: vi.fn(), }), + getTargetDir: () => '/tmp/test-workspace', } as unknown as Config; vi.mocked(tokenLimit).mockReturnValue(1000); @@ -536,6 +182,312 @@ describe('ChatCompressionService', () => { expect(result.newHistory).toBeNull(); }); + describe('screenshot-overflow trigger', () => { + const SCREENSHOT_ENV = [ + 'QWEN_COMPACT_SCREENSHOT_TRIGGER', + 'QWEN_COMPACT_SCREENSHOT_THRESHOLD', + 'QWEN_COMPACT_MAX_RECENT_FILES', + 'QWEN_COMPACT_MAX_RECENT_IMAGES', + ]; + beforeEach(() => { + for (const k of SCREENSHOT_ENV) delete process.env[k]; + }); + afterEach(() => { + for (const k of SCREENSHOT_ENV) delete process.env[k]; + }); + + // 4-entry history whose single tool result nests `imageCount` + // screenshots inside functionResponse.parts (the real shape from + // coreToolScheduler.convertToFunctionResponse). + function historyWithToolImages(imageCount: number): Content[] { + const imageParts = Array.from({ length: imageCount }, (_, i) => ({ + inlineData: { mimeType: 'image/png', data: `shot${i}` }, + })); + return [ + { role: 'user', parts: [{ text: 'take screenshots' }] }, + { + role: 'model', + parts: [ + { + functionCall: { + name: 'computer_use__get_app_state', + args: { app: 'Safari' }, + }, + }, + ], + }, + { + role: 'user', + parts: [ + { + functionResponse: { + name: 'computer_use__get_app_state', + response: { output: '' }, + parts: imageParts, + } as unknown as NonNullable< + Content['parts'] + >[number]['functionResponse'], + }, + ], + }, + { role: 'model', parts: [{ text: 'captured' }] }, + ]; + } + + function mockSummarySideQuery() { + const generateText = vi.fn().mockResolvedValue({ + text: 'Summary', + usage: { + promptTokenCount: 49_000, + candidatesTokenCount: 1_500, + totalTokenCount: 50_500, + }, + }); + vi.mocked(mockConfig.getBaseLlmClient).mockReturnValue({ + generateText, + } as unknown as BaseLlmClient); + return generateText; + } + + function setWindow128k() { + // 128K window → auto ≈ 95K. originalTokenCount 50K is below auto, so + // the token gate alone would NOOP; only the screenshot trigger can + // force compression in these tests. + vi.mocked(mockConfig.getContentGeneratorConfig).mockReturnValue({ + model: 'gemini-pro', + contextWindowSize: 128_000, + } as unknown as ReturnType); + } + + it('fires compaction when tool-image count reaches the threshold, even below the token threshold', async () => { + vi.mocked(mockChat.getHistory).mockReturnValue(historyWithToolImages(3)); + vi.mocked(mockConfig.getChatCompression).mockReturnValue({ + enableScreenshotTrigger: true, + screenshotTriggerThreshold: 3, + } as ReturnType); + setWindow128k(); + const generateText = mockSummarySideQuery(); + + const result = await service.compress(mockChat, { + promptId: mockPromptId, + force: false, + model: mockModel, + config: mockConfig, + consecutiveFailures: 0, + originalTokenCount: 50_000, + }); + + expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED); + expect(generateText).toHaveBeenCalled(); + }); + + it('does NOT fire when the trigger is disabled (NOOP below token threshold despite many images)', async () => { + vi.mocked(mockChat.getHistory).mockReturnValue(historyWithToolImages(20)); + vi.mocked(mockConfig.getChatCompression).mockReturnValue({ + enableScreenshotTrigger: false, + screenshotTriggerThreshold: 3, + } as ReturnType); + setWindow128k(); + const generateText = mockSummarySideQuery(); + + const result = await service.compress(mockChat, { + promptId: mockPromptId, + force: false, + model: mockModel, + config: mockConfig, + consecutiveFailures: 0, + originalTokenCount: 50_000, + }); + + expect(result.info.compressionStatus).toBe(CompressionStatus.NOOP); + expect(generateText).not.toHaveBeenCalled(); + }); + + it('does NOT fire when tool-image count is below the threshold', async () => { + vi.mocked(mockChat.getHistory).mockReturnValue(historyWithToolImages(2)); + vi.mocked(mockConfig.getChatCompression).mockReturnValue({ + enableScreenshotTrigger: true, + screenshotTriggerThreshold: 50, + } as ReturnType); + setWindow128k(); + const generateText = mockSummarySideQuery(); + + const result = await service.compress(mockChat, { + promptId: mockPromptId, + force: false, + model: mockModel, + config: mockConfig, + consecutiveFailures: 0, + originalTokenCount: 50_000, + }); + + expect(result.info.compressionStatus).toBe(CompressionStatus.NOOP); + expect(generateText).not.toHaveBeenCalled(); + }); + + it('reads threshold + enable flag from QWEN_COMPACT_* env over settings', async () => { + vi.mocked(mockChat.getHistory).mockReturnValue(historyWithToolImages(4)); + // Settings would NOT trigger (threshold 50); env lowers it to 4 and + // force-enables, so the env values must win. + vi.mocked(mockConfig.getChatCompression).mockReturnValue({ + enableScreenshotTrigger: false, + screenshotTriggerThreshold: 50, + } as ReturnType); + process.env['QWEN_COMPACT_SCREENSHOT_TRIGGER'] = 'true'; + process.env['QWEN_COMPACT_SCREENSHOT_THRESHOLD'] = '4'; + setWindow128k(); + const generateText = mockSummarySideQuery(); + + const result = await service.compress(mockChat, { + promptId: mockPromptId, + force: false, + model: mockModel, + config: mockConfig, + consecutiveFailures: 0, + originalTokenCount: 50_000, + }); + + expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED); + expect(generateText).toHaveBeenCalled(); + }); + }); + + it('treats an all- summary as empty (no [Summary unavailable] silent success)', async () => { + // The side-query returns ONLY an block (no ). + // Raw body is non-empty but it strips to nothing. isSummaryEmpty must + // check the STRIPPED summary so this takes the FAILED_EMPTY path instead + // of "succeeding" with `[Summary unavailable]` as the agent's only context. + vi.mocked(mockChat.getHistory).mockReturnValue([ + { role: 'user', parts: [{ text: 'do the thing' }] }, + { role: 'model', parts: [{ text: 'working' }] }, + { role: 'user', parts: [{ text: 'continue' }] }, + { role: 'model', parts: [{ text: 'more' }] }, + ]); + const generateText = vi.fn().mockResolvedValue({ + text: 'thinking, but I never produced a state_snapshot', + usage: { + promptTokenCount: 49_000, + candidatesTokenCount: 200, + totalTokenCount: 49_200, + }, + }); + vi.mocked(mockConfig.getBaseLlmClient).mockReturnValue({ + generateText, + } as unknown as BaseLlmClient); + + const result = await service.compress(mockChat, { + promptId: mockPromptId, + force: true, + model: mockModel, + config: mockConfig, + consecutiveFailures: 0, + originalTokenCount: 100_000, + }); + + expect(result.info.compressionStatus).toBe( + CompressionStatus.COMPRESSION_FAILED_EMPTY_SUMMARY, + ); + expect(result.newHistory).toBeNull(); + }); + + it('manual /compress strips a trailing orphaned functionCall from the post-compact history', async () => { + // History ends with model+functionCall and NO functionResponse (an + // interrupted tool call). On manual /compress there is no pending + // response, so preserving it would emit model[fc] then the next user + // text turn → API 400. The post-compact history must not end with it. + vi.mocked(mockChat.getHistory).mockReturnValue([ + { role: 'user', parts: [{ text: 'read the file' }] }, + { + role: 'model', + parts: [ + { functionCall: { name: 'read_file', args: { file_path: '/x.ts' } } }, + ], + }, + ]); + const generateText = vi.fn().mockResolvedValue({ + text: 'read', + usage: { + promptTokenCount: 49_000, + candidatesTokenCount: 1_500, + totalTokenCount: 50_500, + }, + }); + vi.mocked(mockConfig.getBaseLlmClient).mockReturnValue({ + generateText, + } as unknown as BaseLlmClient); + + const result = await service.compress(mockChat, { + promptId: mockPromptId, + force: true, // → compactTrigger 'manual' + model: mockModel, + config: mockConfig, + consecutiveFailures: 0, + originalTokenCount: 100_000, + }); + + expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED); + const last = result.newHistory![result.newHistory!.length - 1]; + const lastIsOrphanFc = + last.role === 'model' && (last.parts ?? []).some((p) => !!p.functionCall); + expect(lastIsOrphanFc).toBe(false); + }); + + it('degrades to summary+ack (folding trailing fc) when composePostCompactHistory throws', async () => { + // A restoration-assembly throw must NOT escape to sendMessageStream + // (which would crash the turn AND bypass the COMPRESSION_FAILED breaker). + // It degrades to a valid post-compact history; an auto-compaction trailing + // functionCall is folded into the ack so a pending functionResponse keeps + // its match (and the trailing turn's text is dropped, per the composer). + vi.mocked(mockChat.getHistory).mockReturnValue([ + { role: 'user', parts: [{ text: 'go' }] }, + { role: 'model', parts: [{ text: 'thinking' }] }, + { role: 'user', parts: [{ text: 'go on' }] }, + { + role: 'model', + parts: [ + { text: 'let me read it' }, + { functionCall: { name: 'read_file', args: { file_path: '/x.ts' } } }, + ], + }, + ]); + const generateText = vi.fn().mockResolvedValue({ + text: 'x', + usage: { + promptTokenCount: 49_000, + candidatesTokenCount: 1_500, + totalTokenCount: 50_500, + }, + }); + vi.mocked(mockConfig.getBaseLlmClient).mockReturnValue({ + generateText, + } as unknown as BaseLlmClient); + const composeSpy = vi + .spyOn(postCompactModule, 'composePostCompactHistory') + .mockRejectedValue(new Error('EACCES: simulated disk failure')); + + const result = await service.compress(mockChat, { + promptId: mockPromptId, + force: true, + trigger: 'auto', // keep the trailing fc (manual would strip it) + model: mockModel, + config: mockConfig, + consecutiveFailures: 0, + originalTokenCount: 100_000, + }); + + expect(composeSpy).toHaveBeenCalled(); + // Degraded success — not an escape, not a compression failure. + expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED); + const last = result.newHistory![result.newHistory!.length - 1]; + expect(last.role).toBe('model'); + expect(last.parts?.some((p) => p.text)).toBe(true); // ack text + expect(last.parts?.some((p) => !!p.functionCall)).toBe(true); // folded fc + const ackText = (last.parts ?? []) + .map((p) => (p as { text?: string }).text ?? '') + .join(' '); + expect(ackText).not.toContain('let me read it'); // trailing text dropped + }); + it('silently ignores the deprecated chatCompression.contextPercentageThreshold = 0 (no longer disables compaction)', async () => { // Pre-PR #4168, setting contextPercentageThreshold = 0 short-circuited // compress() at the cheap-gate (NOOP). The field was removed from @@ -591,47 +543,6 @@ describe('ChatCompressionService', () => { expect(mockGenerateContent).toHaveBeenCalled(); }); - it('should return NOOP when historyToCompress is below MIN_COMPRESSION_FRACTION of total', async () => { - // Construct a history where the split point lands on the 2nd regular user - // message (index 2), but indices 0-1 are tiny relative to the huge content - // at index 2. historyToCompress = [0,1] will be << 5% of totalCharCount. - const hugeContent = 'x'.repeat(100000); - const history: Content[] = [ - { role: 'user', parts: [{ text: 'hello' }] }, - { role: 'model', parts: [{ text: 'world' }] }, - // Huge user message pushes the cumulative well past the split threshold - { role: 'user', parts: [{ text: hugeContent }] }, - // Pending functionCall prevents returning contents.length, - // so the fallback split at index 2 is used - { - role: 'model', - parts: [{ functionCall: { name: 'process', args: {} } }], - }, - ]; - vi.mocked(mockChat.getHistory).mockReturnValue(history); - vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(100); - vi.mocked(tokenLimit).mockReturnValue(1000); - - const mockGenerateContent = vi.fn(); - vi.mocked(mockConfig.getBaseLlmClient).mockReturnValue({ - generateText: mockGenerateContent, - } as unknown as BaseLlmClient); - - // force=true bypasses the token threshold gate so we exercise the 5% guard - const result = await service.compress(mockChat, { - promptId: mockPromptId, - force: true, - model: mockModel, - config: mockConfig, - consecutiveFailures: 0, - originalTokenCount: uiTelemetryService.getLastPromptTokenCount(), - }); - - expect(result.info.compressionStatus).toBe(CompressionStatus.NOOP); - expect(result.newHistory).toBeNull(); - expect(mockGenerateContent).not.toHaveBeenCalled(); - }); - it('should compress if over token threshold', async () => { const history: Content[] = [ { role: 'user', parts: [{ text: 'msg1' }] }, @@ -671,7 +582,9 @@ describe('ChatCompressionService', () => { expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED); expect(result.info.newTokenCount).toBe(250); // 800 - (1600 - 1000) + 50 expect(result.newHistory).not.toBeNull(); - expect(result.newHistory![0].parts![0].text).toBe('Summary'); + // postProcessSummary appends the resume trailer to the summary body, + // so it's "Summary\n\n" rather than a strict equality. + expect(result.newHistory![0].parts![0].text).toContain('Summary'); expect(mockGenerateContent).toHaveBeenCalled(); expect(mockGetHookSystem).toHaveBeenCalled(); }); @@ -1734,366 +1647,6 @@ describe('ChatCompressionService', () => { expect(mockFirePostCompactEvent).not.toHaveBeenCalled(); }); }); - - describe('orphaned trailing funcCall handling', () => { - it('should compress everything when force=true and last message is an orphaned funcCall', async () => { - // Issue #2647: tool-heavy conversation interrupted/crashed while a tool - // was still running. The funcCall will never get a response since the agent - // is idle. Manual /compress strips the orphaned funcCall, then compresses - // the remaining history normally. - const history: Content[] = [ - { role: 'user', parts: [{ text: 'Fix all TypeScript errors.' }] }, - { - role: 'model', - parts: [{ functionCall: { name: 'glob', args: {} } }], - }, - { - role: 'user', - parts: [ - { - functionResponse: { - name: 'glob', - response: { result: 'files...' }, - }, - }, - ], - }, - { - role: 'model', - parts: [{ functionCall: { name: 'readFile', args: {} } }], - }, - { - role: 'user', - parts: [ - { - functionResponse: { - name: 'readFile', - response: { result: 'code...' }, - }, - }, - ], - }, - // orphaned funcCall — agent was interrupted before getting a response - { - role: 'model', - parts: [{ functionCall: { name: 'editFile', args: {} } }], - }, - ]; - vi.mocked(mockChat.getHistory).mockReturnValue(history); - vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue( - 100, - ); - vi.mocked(tokenLimit).mockReturnValue(1000); - - const mockGenerateContent = vi.fn().mockResolvedValue({ - text: 'Summary of all work done', - usage: { - promptTokenCount: 1100, - candidatesTokenCount: 50, - totalTokenCount: 1150, - }, - }); - vi.mocked(mockConfig.getBaseLlmClient).mockReturnValue({ - generateText: mockGenerateContent, - } as unknown as BaseLlmClient); - - const result = await service.compress(mockChat, { - promptId: mockPromptId, - force: true, - // force=true (manual /compress) - model: mockModel, - config: mockConfig, - consecutiveFailures: 0, - originalTokenCount: uiTelemetryService.getLastPromptTokenCount(), - }); - - // Should compress successfully — orphaned funcCall is stripped first, then - // normal compression runs on the remaining history, historyToKeep is empty - expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED); - expect(result.newHistory).not.toBeNull(); - // Reconstructed history: [User(summary), Model("Got it...")] — valid structure - expect(result.newHistory).toHaveLength(2); - expect(result.newHistory![0].role).toBe('user'); - expect(result.newHistory![1].role).toBe('model'); - // The orphaned funcCall is stripped before compression, so only the first 5 - // messages are sent, plus the compression instruction (+1) = history.length total. - const optionsArg = mockGenerateContent.mock.calls[0][0]; - expect(optionsArg.contents.length).toBe(history.length); // (history.length - 1) messages + 1 instruction - }); - - // Shared fixture for the two trailing-in-flight-funcCall scenarios below: - // both auto-compress (force=false) and hard-rescue (force=true, - // trigger='auto') see the same history snapshot — a tool loop where the - // last message is a model funcCall whose matching funcResponse is about - // to arrive in the pending userContent (not in history yet). The only - // thing that differs between the two tests is the `compress(...)` call - // options and the per-test assertions. - const setupInFlightFuncCallFixture = () => { - const history: Content[] = [ - { role: 'user', parts: [{ text: 'Fix all TypeScript errors.' }] }, - { - role: 'model', - parts: [{ functionCall: { name: 'glob', args: {} } }], - }, - { - role: 'user', - parts: [ - { - functionResponse: { - name: 'glob', - response: { result: 'files...' }, - }, - }, - ], - }, - // Trailing funcCall: matching funcResponse is in the pending - // userContent, not in history yet — active, not orphaned. - { - role: 'model', - parts: [{ functionCall: { name: 'readFile', args: {} } }], - }, - ]; - vi.mocked(mockChat.getHistory).mockReturnValue(history); - vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue( - 800, - ); - vi.mocked(mockConfig.getContentGeneratorConfig).mockReturnValue({ - model: 'gemini-pro', - contextWindowSize: 1000, - } as unknown as ReturnType); - - const mockGenerateContent = vi.fn().mockResolvedValue({ - text: 'state snapshot summary', - usage: { - promptTokenCount: 2000, - candidatesTokenCount: 50, - totalTokenCount: 2050, - }, - }); - vi.mocked(mockConfig.getBaseLlmClient).mockReturnValue({ - generateText: mockGenerateContent, - } as unknown as BaseLlmClient); - - return { history, mockGenerateContent }; - }; - - it('compresses-most without orphaning when last entry is in-flight funcCall (auto-compress)', async () => { - // Auto-compress fires BEFORE the matching funcResponse is sent back to - // the model. The trailing funcCall must be retained (its response is - // coming); the in-flight fallback compresses everything safely before - // it. Pre-refactor this returned NOOP, leaving the chat to grow until - // it 400'd. - const { mockGenerateContent } = setupInFlightFuncCallFixture(); - - const result = await service.compress(mockChat, { - promptId: mockPromptId, - force: false, - model: mockModel, - config: mockConfig, - consecutiveFailures: 0, - originalTokenCount: uiTelemetryService.getLastPromptTokenCount(), - }); - - expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED); - expect(mockGenerateContent).toHaveBeenCalledTimes(1); - // Trailing in-flight functionCall is preserved last in the kept slice - // so the upcoming functionResponse pairs with it. - const newHistory = result.newHistory!; - const last = newHistory[newHistory.length - 1]; - expect(last.role).toBe('model'); - expect(last.parts?.some((p) => p.functionCall)).toBe(true); - // Strict role alternation throughout. - for (let i = 1; i < newHistory.length; i++) { - expect(newHistory[i].role).not.toBe(newHistory[i - 1].role); - } - }); - - it('preserves trailing model+funcCall under hard-rescue (force=true + trigger=auto)', async () => { - // Hard-rescue fires from inside sendMessageStream() BEFORE the pending - // userContent (a funcResponse) is pushed onto history. At that moment - // the trailing model+funcCall is ACTIVE, not orphaned — its matching - // funcResponse is sitting in the pending message about to be appended. - // - // Pre-fix, the service's orphan-strip predicate gated on `force` alone, - // which meant hard-rescue (force=true, trigger='auto') was conflated - // with manual /compress and stripped the active funcCall — corrupting - // tool-call/response pairing on the next API send. Fix: gate the strip - // on `trigger === 'manual'` so only the explicit user-initiated - // /compress path performs the orphan cleanup. - setupInFlightFuncCallFixture(); - - const result = await service.compress(mockChat, { - promptId: mockPromptId, - force: true, - trigger: 'auto', // hard-rescue explicitly signals automatic intent - model: mockModel, - config: mockConfig, - consecutiveFailures: 0, - originalTokenCount: uiTelemetryService.getLastPromptTokenCount(), - }); - - expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED); - // The active funcCall must survive in the post-compression history so - // the about-to-be-pushed funcResponse has its matching tool_use. - const newHistory = result.newHistory!; - const last = newHistory[newHistory.length - 1]; - expect(last.role).toBe('model'); - expect(last.parts?.some((p) => p.functionCall)).toBe(true); - }); - }); - - describe('tool-loop subagent absorption', () => { - // The fresh-user split heuristic produces a tiny compress slice when the - // history is dominated by tool rounds (every user past the task is a - // functionResponse). Without absorption, MIN_COMPRESSION_FRACTION would - // NOOP every send and the subagent eventually hits the 400 it was meant - // to avoid. - it('compresses by absorbing older tool rounds when fresh-user split is too small', async () => { - const FILLER = 'A'.repeat(20_000); - // Auto-compress fires BEFORE the next functionResponse is pushed, so - // the trailing entry is always a model+functionCall with no match yet. - // Build a history with N complete pairs followed by one trailing fc. - const buildHistory = (completePairs: number): Content[] => { - const h: Content[] = [ - { role: 'user', parts: [{ text: 'env-bootstrap' }] }, - { role: 'model', parts: [{ text: 'env-ack' }] }, - { role: 'user', parts: [{ text: 'task: explore' }] }, - ]; - for (let r = 0; r < completePairs; r++) { - h.push({ - role: 'model', - parts: [ - { text: `round ${r}: ${FILLER}` }, - { functionCall: { name: 'glob', args: { pattern: '**/*.md' } } }, - ], - }); - h.push({ - role: 'user', - parts: [ - { - functionResponse: { name: 'glob', response: { result: 'x' } }, - }, - ], - }); - } - // Trailing model+fc whose response is about to be sent. - h.push({ - role: 'model', - parts: [ - { text: `round ${completePairs}: ${FILLER}` }, - { functionCall: { name: 'glob', args: { pattern: '**/*.md' } } }, - ], - }); - return h; - }; - - // Five complete tool rounds + 1 trailing fc → 5 pairs in keep; absorbs - // 3 older pairs and retains the 2 most recent (plus the trailing fc). - vi.mocked(mockChat.getHistory).mockReturnValue(buildHistory(5)); - vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue( - 80_000, - ); - vi.mocked(mockConfig.getContentGeneratorConfig).mockReturnValue({ - model: 'gemini-pro', - contextWindowSize: 100_000, - } as unknown as ReturnType); - - const mockGenerateContent = vi.fn().mockResolvedValue({ - text: 'state snapshot summary', - usage: { - promptTokenCount: 60_000, - candidatesTokenCount: 200, - totalTokenCount: 60_200, - }, - }); - vi.mocked(mockConfig.getBaseLlmClient).mockReturnValue({ - generateText: mockGenerateContent, - } as unknown as BaseLlmClient); - - const result = await service.compress(mockChat, { - promptId: mockPromptId, - force: false, - model: mockModel, - config: mockConfig, - consecutiveFailures: 0, - originalTokenCount: uiTelemetryService.getLastPromptTokenCount(), - }); - - expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED); - expect(result.newHistory).not.toBeNull(); - expect(mockGenerateContent).toHaveBeenCalledTimes(1); - - const newHistory = result.newHistory!; - // [summary_user, summary_ack_model, continuation_bridge_user, ...keep] - // where keep starts with the retained model+functionCall. - expect(newHistory[0].role).toBe('user'); - expect(newHistory[0].parts?.[0].text).toBe('state snapshot summary'); - expect(newHistory[1].role).toBe('model'); - expect(newHistory[2].role).toBe('user'); - expect(newHistory[2].parts?.[0].text).toMatch(/Continue/); - // Retained two complete pairs (4 entries) + trailing model+fc = 5. - expect(newHistory.slice(3)).toHaveLength(5); - expect(newHistory[3].role).toBe('model'); - expect(newHistory[3].parts?.some((p) => p.functionCall)).toBe(true); - expect(newHistory[4].role).toBe('user'); - expect(newHistory[4].parts?.some((p) => p.functionResponse)).toBe(true); - // Trailing model+fc remains last so the upcoming functionResponse pushed - // by sendMessageStream pairs with it correctly. - const last = newHistory[newHistory.length - 1]; - expect(last.role).toBe('model'); - expect(last.parts?.some((p) => p.functionCall)).toBe(true); - - // Strict role alternation throughout the new history. - for (let i = 1; i < newHistory.length; i++) { - expect(newHistory[i].role).not.toBe(newHistory[i - 1].role); - } - }); - - it('NOOPs when the keep slice has too few tool rounds to absorb', async () => { - const FILLER = 'A'.repeat(20_000); - const history: Content[] = [ - { role: 'user', parts: [{ text: 'env-bootstrap' }] }, - { role: 'model', parts: [{ text: 'env-ack' }] }, - { role: 'user', parts: [{ text: 'task' }] }, - { - role: 'model', - parts: [ - { text: FILLER }, - { functionCall: { name: 'glob', args: {} } }, - ], - }, - ]; - vi.mocked(mockChat.getHistory).mockReturnValue(history); - // Set originalTokenCount above the threshold gate (0.7 * 30000 = 21000) - // so the test actually exercises findCompressSplitPoint and the - // MIN_COMPRESSION_FRACTION decision rather than short-circuiting at - // the cheap-gate. - vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue( - 22_000, - ); - vi.mocked(mockConfig.getContentGeneratorConfig).mockReturnValue({ - model: 'gemini-pro', - contextWindowSize: 30_000, - } as unknown as ReturnType); - - const mockGenerateContent = vi.fn(); - vi.mocked(mockConfig.getBaseLlmClient).mockReturnValue({ - generateText: mockGenerateContent, - } as unknown as BaseLlmClient); - - const result = await service.compress(mockChat, { - promptId: mockPromptId, - force: false, - model: mockModel, - config: mockConfig, - consecutiveFailures: 0, - originalTokenCount: uiTelemetryService.getLastPromptTokenCount(), - }); - - expect(result.info.compressionStatus).toBe(CompressionStatus.NOOP); - expect(mockGenerateContent).not.toHaveBeenCalled(); - }); - }); }); describe('ChatCompressionService.compress sideQuery config', () => { @@ -2136,6 +1689,7 @@ describe('ChatCompressionService.compress sideQuery config', () => { getModel: () => 'test-model', getApprovalMode: () => 'default', getDebugLogger: () => ({ warn: vi.fn(), debug: vi.fn() }), + getTargetDir: () => '/tmp/test-workspace', } as unknown as Config; const service = new ChatCompressionService(); @@ -2200,6 +1754,7 @@ describe('ChatCompressionService.compress sideQuery config', () => { getModel: () => 'test-model', getApprovalMode: () => 'default', getDebugLogger: () => ({ warn, debug: vi.fn() }), + getTargetDir: () => '/tmp/test-workspace', } as unknown as Config; const result = await new ChatCompressionService().compress(mockChat, { @@ -2257,6 +1812,7 @@ describe('ChatCompressionService.compress cheap-gate uses estimated tokens', () getModel: () => 'test-model', getApprovalMode: () => 'default', getDebugLogger: () => ({ warn: vi.fn(), debug: vi.fn() }), + getTargetDir: () => '/tmp/test-workspace', } as unknown as Config; } @@ -2376,6 +1932,112 @@ describe('computeThresholds', () => { }); }); +describe('ChatCompressionService.compress — claude-code-style full-history compression', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + function makeFakeChat(history: Content[]): GeminiChat { + const getHistoryMock = vi.fn().mockReturnValue(history); + return { + getHistory: getHistoryMock, + getHistoryShallow: getHistoryMock, + } as unknown as GeminiChat; + } + + function makeFakeConfig(): Config { + return { + getChatCompression: vi.fn(), + getBaseLlmClient: vi.fn(), + getContentGeneratorConfig: vi + .fn() + .mockReturnValue({ contextWindowSize: 200_000 }), + getHookSystem: vi.fn().mockReturnValue({ + firePreCompactEvent: vi.fn().mockResolvedValue(undefined), + firePostCompactEvent: vi.fn().mockResolvedValue(undefined), + }), + getModel: () => 'test-model', + getApprovalMode: () => 'default', + getDebugLogger: () => ({ warn: vi.fn(), debug: vi.fn() }), + getTargetDir: () => '/tmp/test-workspace', + } as unknown as Config; + } + + it('sends the ENTIRE history to the summary side-query (no split)', async () => { + const runSideQuerySpy = vi + .spyOn(sideQueryModule, 'runSideQuery') + .mockResolvedValue({ + text: 'TEST SUMMARY', + usage: { + promptTokenCount: 100, + candidatesTokenCount: 50, + totalTokenCount: 150, + }, + } as never); + + const history: Content[] = [ + { role: 'user', parts: [{ text: 'first request' }] }, + { role: 'model', parts: [{ text: 'first reply' }] }, + { role: 'user', parts: [{ text: 'second request' }] }, + { role: 'model', parts: [{ text: 'second reply' }] }, + ]; + + const service = new ChatCompressionService(); + await service.compress(makeFakeChat(history), { + promptId: 'p', + force: true, + model: 'qwen-vl', + config: makeFakeConfig(), + consecutiveFailures: 0, + originalTokenCount: 180_000, + trigger: 'manual', + }); + + const calledWith = runSideQuerySpy.mock.calls[0]![1] as { + contents: Array<{ parts: Array<{ text?: string }> }>; + }; + // Full 4 history entries + 1 trailing scratchpad prompt = 5 contents. + expect(calledWith.contents).toHaveLength(5); + expect(calledWith.contents[0].parts[0].text).toContain('first request'); + }); + + it('produces newHistory composed via composePostCompactHistory', async () => { + vi.spyOn(sideQueryModule, 'runSideQuery').mockResolvedValue({ + text: 'SUM_TXT', + usage: { + // newTokenCount = 180_000 - (170_000 - 1000) + 500 = 11_500 <= 180_000 + promptTokenCount: 170_000, + candidatesTokenCount: 500, + totalTokenCount: 170_500, + }, + } as never); + + const history: Content[] = [ + { role: 'user', parts: [{ text: 'hi' }] }, + { role: 'model', parts: [{ text: 'hello' }] }, + { role: 'user', parts: [{ text: 'how are you' }] }, + { role: 'model', parts: [{ text: 'fine' }] }, + ]; + + const service = new ChatCompressionService(); + const result = await service.compress(makeFakeChat(history), { + promptId: 'p', + force: true, + model: 'qwen-vl', + config: makeFakeConfig(), + consecutiveFailures: 0, + originalTokenCount: 180_000, + trigger: 'manual', + }); + + expect(result.newHistory).not.toBeNull(); + expect(result.newHistory![0].role).toBe('user'); + const firstPart = result.newHistory![0].parts?.[0] as { text?: string }; + expect(firstPart.text).toContain('SUM_TXT'); + expect(result.newHistory![1].role).toBe('model'); + }); +}); + describe('ChatCompressionService.compress cheap-gate uses computeThresholds.auto', () => { afterEach(() => { vi.restoreAllMocks(); @@ -2408,6 +2070,7 @@ describe('ChatCompressionService.compress cheap-gate uses computeThresholds.auto getModel: () => 'test-model', getApprovalMode: () => 'default', getDebugLogger: () => ({ warn: vi.fn(), debug: vi.fn() }), + getTargetDir: () => '/tmp/test-workspace', } as unknown as Config; } @@ -2453,3 +2116,140 @@ describe('ChatCompressionService.compress cheap-gate uses computeThresholds.auto expect(result.info.compressionStatus).not.toBe(CompressionStatus.NOOP); }); }); + +describe('ChatCompressionService.compress — single-turn computer-use regression', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + function makeFakeChat(history: Content[]): GeminiChat { + const getHistoryMock = vi.fn().mockReturnValue(history); + return { + getHistory: getHistoryMock, + getHistoryShallow: getHistoryMock, + } as unknown as GeminiChat; + } + + function makeFakeConfig(): Config { + return { + getChatCompression: vi.fn(), + getBaseLlmClient: vi.fn(), + getContentGeneratorConfig: vi + .fn() + .mockReturnValue({ contextWindowSize: 200_000 }), + getHookSystem: vi.fn().mockReturnValue({ + firePreCompactEvent: vi.fn().mockResolvedValue(undefined), + firePostCompactEvent: vi.fn().mockResolvedValue(undefined), + }), + getModel: () => 'test-model', + getApprovalMode: () => 'default', + getDebugLogger: () => ({ warn: vi.fn(), debug: vi.fn() }), + getTargetDir: () => '/tmp/test-workspace', + } as unknown as Config; + } + + it('preserves the user prompt verbatim in summary and restores 3 most recent screenshots', async () => { + // Reproduces the "single-turn long task" scenario the rewrite targets: + // ONE user message kicks off many tool calls. OLD behavior with the + // split-point model: 0 entries preserved verbatim when compression + // fires after a tool result (the common case). NEW behavior: summary + // contains the user prompt verbatim (via 9-section prompt template's + // "All user messages" section) + 3 most recent screenshots attached + // as the image restoration block. + // Real shape: the screenshot is nested inside functionResponse.parts, + // exactly as coreToolScheduler.convertToFunctionResponse emits it — NOT + // a top-level sibling. (The earlier sibling shape masked the bug where + // extractRecentImages restored zero screenshots.) + const screenshot = (data: string): Content => ({ + role: 'user', + parts: [ + { + functionResponse: { + name: 'computer_use__get_app_state', + response: { output: 'ok' }, + parts: [{ inlineData: { mimeType: 'image/png', data } }], + } as unknown as NonNullable< + Content['parts'] + >[number]['functionResponse'], + }, + ], + }); + const callScreenshot = (app: string): Content => ({ + role: 'model', + parts: [ + { + functionCall: { + name: 'computer_use__get_app_state', + args: { app }, + }, + }, + ], + }); + + const history: Content[] = [ + { + role: 'user', + parts: [{ text: 'open Safari and read the first headline' }], + }, + callScreenshot('Safari'), + screenshot('s1'), + callScreenshot('Safari'), + screenshot('s2'), + callScreenshot('Safari'), + screenshot('s3'), + callScreenshot('Safari'), + screenshot('s4'), + callScreenshot('Safari'), + screenshot('s5'), + ]; + + vi.spyOn(sideQueryModule, 'runSideQuery').mockResolvedValue({ + text: 'SUMMARY containing "open Safari and read the first headline" verbatim', + usage: { + promptTokenCount: 170_000, + candidatesTokenCount: 500, + totalTokenCount: 170_500, + }, + } as never); + + const service = new ChatCompressionService(); + const result = await service.compress(makeFakeChat(history), { + promptId: 'p', + force: true, + model: 'qwen-vl', + config: makeFakeConfig(), + consecutiveFailures: 0, + originalTokenCount: 180_000, + trigger: 'manual', + }); + + expect(result.newHistory).not.toBeNull(); + const flat = result.newHistory!; + const flatText = flat + .flatMap((c) => c.parts ?? []) + .map((p) => (p as { text?: string }).text ?? '') + .join('\n'); + + // Assertion 1: summary text (mocked) carries the user prompt verbatim. + expect(flatText).toContain('open Safari and read the first headline'); + + // Assertion 2: Image restoration block exists and contains exactly s3, s4, s5 + // (the 3 most recent screenshots), in chronological order. + const inlineDataParts = flat.flatMap((c) => + (c.parts ?? []).filter((p) => + ( + p as { inlineData?: { mimeType?: string } } + ).inlineData?.mimeType?.startsWith('image/'), + ), + ); + expect( + inlineDataParts.map( + (p) => (p as { inlineData: { data: string } }).inlineData.data, + ), + ).toEqual(['s3', 's4', 's5']); + + // Assertion 3: Image metadata header mentions the source tool and args. + expect(flatText).toContain('computer_use__get_app_state'); + expect(flatText).toContain('"app":"Safari"'); + }); +}); diff --git a/packages/core/src/services/chatCompressionService.ts b/packages/core/src/services/chatCompressionService.ts index a6e2434e1c..f584ec0e4c 100644 --- a/packages/core/src/services/chatCompressionService.ts +++ b/packages/core/src/services/chatCompressionService.ts @@ -15,35 +15,18 @@ import { logChatCompression } from '../telemetry/loggers.js'; import { makeChatCompressionEvent } from '../telemetry/types.js'; import { PreCompactTrigger, PostCompactTrigger } from '../hooks/types.js'; import { - DEFAULT_IMAGE_TOKEN_ESTIMATE, estimateContentChars, + resolveCompactionTuning, resolveSlimmingConfig, slimCompactionInput, } from './compactionInputSlimming.js'; -import { estimatePromptTokens } from './tokenEstimation.js'; - -/** - * The fraction of the latest chat history to keep. A value of 0.3 - * means that only the last 30% of the chat history will be kept after compression. - */ -export const COMPRESSION_PRESERVE_THRESHOLD = 0.3; - -/** - * Minimum fraction of history (by character count) that must be compressible - * to proceed with a compression API call. Prevents futile calls where the - * model receives almost no context and generates a useless summary. - */ -export const MIN_COMPRESSION_FRACTION = 0.05; - -/** - * When the trailing entry is an in-flight `model+functionCall` and the regular - * scan finds no clean split past the target fraction, the splitter falls back - * to compressing everything except the last few entries. This constant sets - * how many most-recent complete `(model+functionCall, user+functionResponse)` - * tool rounds are retained as working context (the trailing in-flight call is - * always retained on top of these). - */ -export const TOOL_ROUND_RETAIN_COUNT = 2; +import { CHARS_PER_TOKEN, estimatePromptTokens } from './tokenEstimation.js'; +import { + composePostCompactHistory, + countToolResponseImages, + postProcessSummary, + stripAnalysisBlock, +} from './postCompactAttachments.js'; /** * Hard cap on the compression sideQuery output (summary text only, since @@ -149,118 +132,6 @@ export function computeThresholds(window: number): CompactionThresholds { export type CompactTrigger = 'manual' | 'auto'; -const hasFunctionCall = (content: Content | undefined): boolean => - !!content?.parts?.some((part) => !!part.functionCall); - -const hasFunctionResponse = (content: Content | undefined): boolean => - !!content?.parts?.some((part) => !!part.functionResponse); - -/** - * Walk backward from the trailing in-flight `model+functionCall` and return - * the index after which the most-recent `retainCount` complete tool-round - * pairs sit (plus the trailing fc itself). Used by the splitter's in-flight - * fallback path. Stops counting at the first non-pair encountered, so the - * retain count is best-effort: if there are fewer complete pairs than - * requested, all of them are retained. - */ -function splitPointRetainingTrailingPairs( - contents: Content[], - retainCount: number, -): number { - let pairsFound = 0; - let i = contents.length - 2; - while (i >= 1 && pairsFound < retainCount) { - if (hasFunctionCall(contents[i - 1]) && hasFunctionResponse(contents[i])) { - pairsFound += 1; - i -= 2; - } else { - break; - } - } - return contents.length - (2 * pairsFound + 1); -} - -/** - * Returns the index of the oldest item to keep when compressing. May return - * contents.length which indicates that everything should be compressed. - * - * The algorithm has two phases: - * - * 1. **Scan:** walk left-to-right looking for the first non-functionResponse - * user message that lands past `fraction` of total chars. That's the - * "clean" split — the kept slice starts with a fresh user prompt. - * - * 2. **Fallbacks** (no clean split found): the gate that gets us here has - * already decided we need to compress, so all three fallbacks bias toward - * *more* compression rather than less: - * - * - last entry is `model` without functionCall → compress everything. - * - last entry is `user` with functionResponse → compress everything (the - * trailing tool round is complete; no orphans). - * - last entry is `model` with functionCall (in-flight) → compress - * everything except the trailing call plus the last `retainCount` - * complete tool rounds. The kept slice may start with `model+fc`; - * callers must inject a synthetic continuation user message between - * `summary_ack_model` and the kept slice to preserve role alternation. - * - * The pre-fallback returns of `lastSplitPoint` (compress less) only happen - * for malformed histories that don't end in user/model. - * - * Exported for testing purposes. - */ -export function findCompressSplitPoint( - contents: Content[], - fraction: number, - retainCount = TOOL_ROUND_RETAIN_COUNT, - precomputedCharCounts?: number[], -): number { - if (fraction <= 0 || fraction >= 1) { - throw new Error('Fraction must be between 0 and 1'); - } - - // Slimming-aware char estimator: base64 payloads in inlineData - // would otherwise dominate the split. The caller can pre-compute and - // pass `precomputedCharCounts` to avoid a redundant walk when the - // surrounding compress() loop also needs the values. - // - // NOTE on the fallback: when `precomputedCharCounts` is omitted, we - // use `DEFAULT_IMAGE_TOKEN_ESTIMATE` rather than the user's resolved - // setting / env override. The only production caller is `compress()`, - // which always passes precomputed counts, so the fallback is a - // test-friendly default — not a behavior path users can influence. - // Production callers MUST pass `precomputedCharCounts`. - const charCounts = - precomputedCharCounts ?? - contents.map((content) => - estimateContentChars(content, DEFAULT_IMAGE_TOKEN_ESTIMATE), - ); - const totalCharCount = charCounts.reduce((a, b) => a + b, 0); - const targetCharCount = totalCharCount * fraction; - - let lastSplitPoint = 0; - let cumulativeCharCount = 0; - for (let i = 0; i < contents.length; i++) { - const content = contents[i]; - if (content.role === 'user' && !hasFunctionResponse(content)) { - if (cumulativeCharCount >= targetCharCount) { - return i; - } - lastSplitPoint = i; - } - cumulativeCharCount += charCounts[i]; - } - - const lastContent = contents[contents.length - 1]; - if (lastContent?.role === 'model') { - if (!hasFunctionCall(lastContent)) return contents.length; - return splitPointRetainingTrailingPairs(contents, retainCount); - } - if (lastContent?.role === 'user' && hasFunctionResponse(lastContent)) { - return contents.length; - } - return lastSplitPoint; -} - export interface CompressOptions { promptId: string; force: boolean; @@ -325,6 +196,7 @@ export class ChatCompressionService { const compactTrigger = trigger ?? (force ? 'manual' : 'auto'); const chatCompressionSettings = config.getChatCompression(); const slimmingConfig = resolveSlimmingConfig(chatCompressionSettings); + const tuning = resolveCompactionTuning(chatCompressionSettings); // Cheap gates first — these don't need the curated history. Forward // originalTokenCount on NOOP (matching the threshold-gate branch below) @@ -365,14 +237,26 @@ export class ChatCompressionService { ) : originalTokenCount; if (effectiveTokens < auto) { - return { - newHistory: null, - info: { - originalTokenCount, - newTokenCount: originalTokenCount, - compressionStatus: CompressionStatus.NOOP, - }, - }; + // Screenshot-overflow trigger: even below the token threshold, + // compact once tool-returned images accumulate past the configured + // count, so computer-use sessions don't drown the model in stale + // screenshots. Only counted in the would-be-NOOP path and only when + // enabled, so the common case pays nothing. Counts NESTED tool media + // only (countToolResponseImages), not user-pasted top-level images. + const screenshotOverflow = + tuning.enableScreenshotTrigger && + countToolResponseImages(chat.getHistoryShallow(true)) >= + tuning.screenshotTriggerThreshold; + if (!screenshotOverflow) { + return { + newHistory: null, + info: { + originalTokenCount, + newTokenCount: originalTokenCount, + compressionStatus: CompressionStatus.NOOP, + }, + }; + } } } @@ -407,52 +291,14 @@ export class ChatCompressionService { } } - // Only manual `/compress` (trigger='manual') performs the orphan-strip: - // if the chat was interrupted with a trailing model funcCall whose - // funcResponse never arrived, the user-initiated /compress between - // turns can safely drop it before computing the split point. - // - // Both automatic paths (trigger='auto') — cheap-gate (force=false) AND - // hard-rescue (force=true) — must NOT strip. They fire inside - // sendMessageStream() BEFORE the pending funcResponse is pushed onto - // history, so the trailing funcCall is still active, not orphaned. - // - // Gating on `trigger === 'manual'` instead of `force` disambiguates - // "user wants this compressed now, history can be mutated" from - // "automatic compression mid-turn, history snapshot is live state and - // must be preserved verbatim". Earlier the predicate used `force`, - // which is correct for manual /compress (force=true, trigger='manual') - // but conflated hard-rescue (force=true, trigger='auto') and silently - // stripped active funcCalls there. - const lastMessage = curatedHistory[curatedHistory.length - 1]; - const hasOrphanedFuncCall = - compactTrigger === 'manual' && - lastMessage?.role === 'model' && - lastMessage.parts?.some((p) => !!p.functionCall); - const historyForSplit = hasOrphanedFuncCall - ? curatedHistory.slice(0, -1) - : curatedHistory; + // CLAUDE-CODE-STYLE FULL-HISTORY COMPRESSION: the entire curated + // history is sent to the summary side-query (no split, no tail + // preservation), and the post-compact history is assembled by + // composePostCompactHistory below (summary + model ack + recent + // file restores + recent image restore). - // Precompute charCounts once and share with the splitter + the - // MIN_COMPRESSION_FRACTION guard below, avoiding two extra walks. - const charCounts = historyForSplit.map((c) => - estimateContentChars(c, slimmingConfig.imageTokenEstimate), - ); - const splitPoint = findCompressSplitPoint( - historyForSplit, - 1 - COMPRESSION_PRESERVE_THRESHOLD, - TOOL_ROUND_RETAIN_COUNT, - charCounts, - ); - - const historyToCompress = historyForSplit.slice(0, splitPoint); - const historyToKeep = historyForSplit.slice(splitPoint); - // The in-flight fallback path may produce a kept slice starting with - // model+functionCall; the post-summary history needs a synthetic user - // between the summary's model_ack and the kept entries. - const keepNeedsContinuationBridge = historyToKeep[0]?.role === 'model'; - - if (historyToCompress.length === 0) { + // Guard: need at least a user+model pair for a meaningful summary. + if (curatedHistory.length < 2) { return { newHistory: null, info: { @@ -463,28 +309,10 @@ export class ChatCompressionService { }; } - // Guard: if historyToCompress is too small relative to the total history, - // skip compression. This prevents futile API calls where the model receives - // almost no context and generates a useless "summary" that inflates tokens. - let compressCharCount = 0; - for (let i = 0; i < splitPoint; i++) compressCharCount += charCounts[i]!; - const totalCharCount = charCounts.reduce((a, b) => a + b, 0); - if ( - totalCharCount > 0 && - compressCharCount / totalCharCount < MIN_COMPRESSION_FRACTION - ) { - return { - newHistory: null, - info: { - originalTokenCount, - newTokenCount: originalTokenCount, - compressionStatus: CompressionStatus.NOOP, - }, - }; - } - - // Slim the side-query; live history unchanged. - const slim = slimCompactionInput(historyToCompress); + // Slim the side-query input: replace inlineData with placeholders. + // The original history (with images) is preserved separately for + // the post-compact image restoration block. + const slim = slimCompactionInput(curatedHistory); if (slim.stats.imagesStripped > 0 || slim.stats.documentsStripped > 0) { config .getDebugLogger() @@ -507,7 +335,7 @@ export class ChatCompressionService { role: 'user', parts: [ { - text: 'First, reason in your scratchpad. Then, generate the .', + text: 'First, reason in your block. Then, produce the XML.', }, ], }, @@ -524,7 +352,15 @@ export class ChatCompressionService { promptId, }); const summary = summaryResult.text; - const isSummaryEmpty = !summary || summary.trim().length === 0; + // Check the PROCESSED summary: postProcessSummary strips + // blocks, so a response that is ONLY ... (no + // ) has a non-empty RAW body but strips to nothing. If + // we gated on the raw body, compaction would "succeed" and the agent + // would resume with `[Summary unavailable]` as its only context — total + // amnesia with green metrics. Treat strip-to-empty as an empty summary + // so it takes the COMPRESSION_FAILED_EMPTY_SUMMARY path (NOOP) instead. + const isSummaryEmpty = + !summary || stripAnalysisBlock(summary).trim().length === 0; const compressionUsageMetadata = summaryResult.usage; const compressionInputTokenCount = compressionUsageMetadata?.promptTokenCount; @@ -588,41 +424,81 @@ export class ChatCompressionService { let canCalculateNewTokenCount = false; if (!isSummaryEmpty) { - extraHistory = [ - { - role: 'user', - parts: [{ text: summary }], - }, - { - role: 'model', - parts: [{ text: 'Got it. Thanks for the additional context!' }], - }, - // When the kept slice starts with model+functionCall (because - // tool-round absorption pulled the only fresh user message into - // compress), inject a synthetic continuation prompt so the joined - // history alternates correctly. - ...(keepNeedsContinuationBridge - ? [ - { - role: 'user' as const, - parts: [ - { - text: 'Continue with the prior task using the context above.', - }, - ], - }, - ] - : []), - ...historyToKeep, - ]; + // Manual /compress has no pending functionResponse, so a trailing + // model+functionCall is an ORPHAN (e.g. an interrupted/cancelled tool + // call). Preserving it emits model[functionCall] immediately followed + // by the next user TEXT turn, which the API rejects (a functionCall + // must be followed by its functionResponse). Strip it for manual; + // auto-compaction keeps it because the pending functionResponse pairs + // with it (trailingFunctionCallContent). + const lastCurated = curatedHistory[curatedHistory.length - 1]; + const historyForCompose = + compactTrigger === 'manual' && + lastCurated?.role === 'model' && + lastCurated.parts?.some((p) => !!p.functionCall) + ? curatedHistory.slice(0, -1) + : curatedHistory; + + // Use the new composer — assembles summary + ack + file restores + + // image restore. No tail preservation, no continuation bridge. + try { + extraHistory = await composePostCompactHistory( + historyForCompose, + summary, + { + workspaceRoot: config.getTargetDir(), + signal, + maxFiles: tuning.maxRecentFiles, + maxImages: tuning.maxRecentImages, + }, + ); + } catch (err) { + // The summary side-query already succeeded; only restoration + // assembly (disk I/O, history walking) failed. Degrade to + // summary + ack rather than letting the throw escape to + // sendMessageStream — an uncaught error there crashes the active + // turn AND bypasses the COMPRESSION_FAILED breaker. The summary + // still reduces context, so this is a degraded success, not a + // compression failure. + config + .getDebugLogger() + .warn(`[chat-compression] composePostCompactHistory failed: ${err}`); + // Fold a trailing model+functionCall into the ack so a pending + // functionResponse (auto-compaction mid-tool-loop) keeps its matching + // call — otherwise the next request has an orphaned functionResponse + // → 400. (Manual orphans were already stripped above.) Folding into + // the ack avoids a model→model adjacency. + const trailingFc = historyForCompose[historyForCompose.length - 1]; + const fcParts = + trailingFc?.role === 'model' + ? (trailingFc.parts ?? []).filter((p) => !!p.functionCall) + : []; + extraHistory = [ + { role: 'user', parts: [{ text: postProcessSummary(summary) }] }, + { + role: 'model', + parts: [ + { text: 'Got it. Thanks for the additional context!' }, + ...fcParts, + ], + }, + ]; + } // Best-effort token math using *only* model-reported token counts. // - // Note: compressionInputTokenCount includes the compression prompt and - // the extra "reason in your scratchpad" instruction(approx. 1000 tokens), and - // compressionOutputTokenCount reflects the summary tokens only since - // thinking is disabled. - // We accept these inaccuracies to avoid local token estimation. + // Note: compressionInputTokenCount includes the entire compression + // system prompt (the instructions, ~900 tokens) PLUS + // the short kick-off user turn ("First, reason in your + // block. Then, produce the XML.", ~20 tokens) — the + // "approx. 1000 tokens" subtracted below is for that combined fixed + // overhead, not for any single instruction. + // compressionOutputTokenCount reflects the raw model response (i.e. + // + ); the block is stripped + // by postProcessSummary before the summary enters history, so the + // real cost in newHistory is slightly lower than this count + // suggests. We accept that inaccuracy in favor of avoiding local + // token estimation. if ( typeof compressionInputTokenCount === 'number' && compressionInputTokenCount > 0 && @@ -636,6 +512,23 @@ export class ChatCompressionService { (compressionInputTokenCount - 1000) + compressionOutputTokenCount, ); + // The composer injects file-restoration blocks (up to + // maxRecentFiles × 5K tokens) and an image-restoration block (up to + // maxRecentImages images) that are NOT in + // compressionOutputTokenCount. Estimate their + // cost locally so the inflation guard below + // (newTokenCount > originalTokenCount) actually fires when + // attachments dominate the post-compact size, and so + // `lastPromptTokenCount` doesn't under-report the next auto- + // compaction cheap-gate input (Finding 1). + const restorationChars = extraHistory + .slice(2) // skip [summary, model ack] + .reduce( + (acc, c) => + acc + estimateContentChars(c, slimmingConfig.imageTokenEstimate), + 0, + ); + newTokenCount += Math.ceil(restorationChars / CHARS_PER_TOKEN); } } @@ -685,9 +578,18 @@ export class ChatCompressionService { compactTrigger === 'manual' ? PostCompactTrigger.Manual : PostCompactTrigger.Auto; + // Pass the stripped summary (Finding 8a) so hook consumers see + // the same text that lands in history — not the raw side-query + // output with the scratchpad still attached. The + // resume trailer is NOT included; it is wrapper decoration for + // the next agent turn, not state for downstream consumers. await config .getHookSystem() - ?.firePostCompactEvent(postCompactTrigger, summary, signal); + ?.firePostCompactEvent( + postCompactTrigger, + stripAnalysisBlock(summary), + signal, + ); } catch (err) { config.getDebugLogger().warn(`PostCompact hook failed: ${err}`); } diff --git a/packages/core/src/services/compactionInputSlimming.test.ts b/packages/core/src/services/compactionInputSlimming.test.ts index 044ac7ae8e..678e47aa65 100644 --- a/packages/core/src/services/compactionInputSlimming.test.ts +++ b/packages/core/src/services/compactionInputSlimming.test.ts @@ -8,20 +8,33 @@ import type { Content } from '@google/genai'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { DEFAULT_IMAGE_TOKEN_ESTIMATE, + DEFAULT_MAX_RECENT_FILES, + DEFAULT_MAX_RECENT_IMAGES, + DEFAULT_SCREENSHOT_TRIGGER_ENABLED, + DEFAULT_SCREENSHOT_TRIGGER_THRESHOLD, estimateContentChars, estimatePartChars, + resolveCompactionTuning, resolveSlimmingConfig, sanitizeMimeForPlaceholder, slimCompactionInput, } from './compactionInputSlimming.js'; +const COMPACTION_ENV_KEYS = [ + 'QWEN_IMAGE_TOKEN_ESTIMATE', + 'QWEN_COMPACT_MAX_RECENT_FILES', + 'QWEN_COMPACT_MAX_RECENT_IMAGES', + 'QWEN_COMPACT_SCREENSHOT_TRIGGER', + 'QWEN_COMPACT_SCREENSHOT_THRESHOLD', +]; + describe('compactionInputSlimming', () => { beforeEach(() => { - delete process.env['QWEN_IMAGE_TOKEN_ESTIMATE']; + for (const k of COMPACTION_ENV_KEYS) delete process.env[k]; }); afterEach(() => { - delete process.env['QWEN_IMAGE_TOKEN_ESTIMATE']; + for (const k of COMPACTION_ENV_KEYS) delete process.env[k]; }); describe('resolveSlimmingConfig', () => { @@ -58,6 +71,89 @@ describe('compactionInputSlimming', () => { }); }); + describe('resolveCompactionTuning', () => { + it('returns defaults when nothing is set', () => { + const t = resolveCompactionTuning(undefined); + expect(t.maxRecentFiles).toBe(DEFAULT_MAX_RECENT_FILES); + expect(t.maxRecentImages).toBe(DEFAULT_MAX_RECENT_IMAGES); + expect(t.enableScreenshotTrigger).toBe( + DEFAULT_SCREENSHOT_TRIGGER_ENABLED, + ); + expect(t.screenshotTriggerThreshold).toBe( + DEFAULT_SCREENSHOT_TRIGGER_THRESHOLD, + ); + }); + + it('honors settings when env is unset', () => { + const t = resolveCompactionTuning({ + maxRecentFilesToRetain: 2, + maxRecentImagesToRetain: 1, + enableScreenshotTrigger: false, + screenshotTriggerThreshold: 12, + }); + expect(t.maxRecentFiles).toBe(2); + expect(t.maxRecentImages).toBe(1); + expect(t.enableScreenshotTrigger).toBe(false); + expect(t.screenshotTriggerThreshold).toBe(12); + }); + + it('accepts 0 for the retention caps (restore none)', () => { + const t = resolveCompactionTuning({ + maxRecentFilesToRetain: 0, + maxRecentImagesToRetain: 0, + }); + expect(t.maxRecentFiles).toBe(0); + expect(t.maxRecentImages).toBe(0); + }); + + it('env overrides settings for every knob', () => { + process.env['QWEN_COMPACT_MAX_RECENT_FILES'] = '7'; + process.env['QWEN_COMPACT_MAX_RECENT_IMAGES'] = '9'; + process.env['QWEN_COMPACT_SCREENSHOT_TRIGGER'] = '0'; + process.env['QWEN_COMPACT_SCREENSHOT_THRESHOLD'] = '99'; + const t = resolveCompactionTuning({ + maxRecentFilesToRetain: 1, + maxRecentImagesToRetain: 1, + enableScreenshotTrigger: true, + screenshotTriggerThreshold: 5, + }); + expect(t.maxRecentFiles).toBe(7); + expect(t.maxRecentImages).toBe(9); + expect(t.enableScreenshotTrigger).toBe(false); + expect(t.screenshotTriggerThreshold).toBe(99); + }); + + it('parses the boolean env both ways and ignores typos', () => { + process.env['QWEN_COMPACT_SCREENSHOT_TRIGGER'] = 'false'; + expect(resolveCompactionTuning(undefined).enableScreenshotTrigger).toBe( + false, + ); + process.env['QWEN_COMPACT_SCREENSHOT_TRIGGER'] = '1'; + expect(resolveCompactionTuning(undefined).enableScreenshotTrigger).toBe( + true, + ); + // Unrecognized env string falls through to the settings value. + process.env['QWEN_COMPACT_SCREENSHOT_TRIGGER'] = 'yes-please'; + expect( + resolveCompactionTuning({ enableScreenshotTrigger: false }) + .enableScreenshotTrigger, + ).toBe(false); + }); + + it('falls through invalid numeric env to settings, then defaults', () => { + process.env['QWEN_COMPACT_SCREENSHOT_THRESHOLD'] = 'not-a-number'; + expect( + resolveCompactionTuning({ screenshotTriggerThreshold: 33 }) + .screenshotTriggerThreshold, + ).toBe(33); + // Threshold has a min of 1, so 0 is rejected → default. + process.env['QWEN_COMPACT_SCREENSHOT_THRESHOLD'] = '0'; + expect( + resolveCompactionTuning(undefined).screenshotTriggerThreshold, + ).toBe(DEFAULT_SCREENSHOT_TRIGGER_THRESHOLD); + }); + }); + describe('estimatePartChars', () => { it('uses text length for text parts', () => { expect(estimatePartChars({ text: 'hello' }, 1600)).toBe(5); diff --git a/packages/core/src/services/compactionInputSlimming.ts b/packages/core/src/services/compactionInputSlimming.ts index effe5f83c2..17ae2284cc 100644 --- a/packages/core/src/services/compactionInputSlimming.ts +++ b/packages/core/src/services/compactionInputSlimming.ts @@ -98,12 +98,88 @@ function resolveNumber( return defaultValue; } +export const DEFAULT_MAX_RECENT_FILES = 5; +export const DEFAULT_MAX_RECENT_IMAGES = 3; +export const DEFAULT_SCREENSHOT_TRIGGER_ENABLED = true; +export const DEFAULT_SCREENSHOT_TRIGGER_THRESHOLD = 50; + +export interface ResolvedCompactionTuning { + /** Recent files restored after compaction (0 = restore none). */ + maxRecentFiles: number; + /** Recent images restored after compaction (0 = restore none). */ + maxRecentImages: number; + /** Whether tool-image accumulation can trigger auto-compaction. */ + enableScreenshotTrigger: boolean; + /** Tool-image count at or above which the trigger fires (≥ 1). */ + screenshotTriggerThreshold: number; +} + +/** + * Resolves the post-compact retention + screenshot-trigger knobs in + * priority order env > settings > default, reusing the same validation + * rules as `resolveSlimmingConfig`. + * + * The screenshot trigger counts only images nested in + * `functionResponse.parts` (tool results). Compaction replaces those with + * the summary, and the surviving images are re-embedded as TOP-LEVEL parts + * in the restoration block — which the counter ignores. So compaction + * always resets the tool-image count to ~0 and the trigger cannot + * immediately re-fire, independent of `maxRecentImages`. + */ +export function resolveCompactionTuning( + settings: ChatCompressionSettings | undefined, +): ResolvedCompactionTuning { + return { + maxRecentFiles: resolveNumber( + process.env['QWEN_COMPACT_MAX_RECENT_FILES'], + settings?.maxRecentFilesToRetain, + DEFAULT_MAX_RECENT_FILES, + { minInclusive: 0 }, + ), + maxRecentImages: resolveNumber( + process.env['QWEN_COMPACT_MAX_RECENT_IMAGES'], + settings?.maxRecentImagesToRetain, + DEFAULT_MAX_RECENT_IMAGES, + { minInclusive: 0 }, + ), + enableScreenshotTrigger: resolveBoolean( + process.env['QWEN_COMPACT_SCREENSHOT_TRIGGER'], + settings?.enableScreenshotTrigger, + DEFAULT_SCREENSHOT_TRIGGER_ENABLED, + ), + screenshotTriggerThreshold: resolveNumber( + process.env['QWEN_COMPACT_SCREENSHOT_THRESHOLD'], + settings?.screenshotTriggerThreshold, + DEFAULT_SCREENSHOT_TRIGGER_THRESHOLD, + { minInclusive: 1 }, + ), + }; +} + +/** + * Resolves a boolean knob in priority order env > settings > default. + * Accepts `1`/`true` and `0`/`false` (case-sensitive, matching the + * existing `getEmitToolUseSummaries` convention); any other env string + * is ignored so a typo falls through rather than silently flipping the + * flag. + */ +function resolveBoolean( + envValue: string | undefined, + settingsValue: boolean | undefined, + defaultValue: boolean, +): boolean { + if (envValue === '1' || envValue === 'true') return true; + if (envValue === '0' || envValue === 'false') return false; + if (typeof settingsValue === 'boolean') return settingsValue; + return defaultValue; +} + /** * Approximate char count for a single `Part`, used by - * `findCompressSplitPoint` and by the slimming module's own budget + * `estimateContentChars` and by the slimming module's own budget * accounting. Binary parts get a fixed budget (in chars) derived from * the configured token estimate; this keeps base64 payloads from - * skewing the split point or token-budget math. + * skewing compression size estimation or token-budget math. */ export function estimatePartChars( part: Part, @@ -144,8 +220,12 @@ export function estimatePartChars( * qwen-code attaches media here (see * `coreToolScheduler.createFunctionResponsePart`); the standard * `@google/genai` FunctionResponse type does not declare it. + * + * Exported so post-compact image extraction/counting walks the SAME + * carrier the slimmer strips — otherwise the two disagree on where tool + * media lives and screenshots silently vanish from restoration. */ -function getFunctionResponseParts(part: Part): Part[] | undefined { +export function getFunctionResponseParts(part: Part): Part[] | undefined { const fr = part.functionResponse as { parts?: unknown } | undefined; return Array.isArray(fr?.parts) ? (fr.parts as Part[]) : undefined; } diff --git a/packages/core/src/services/postCompactAttachments.test.ts b/packages/core/src/services/postCompactAttachments.test.ts new file mode 100644 index 0000000000..6ee8e9225a --- /dev/null +++ b/packages/core/src/services/postCompactAttachments.test.ts @@ -0,0 +1,1220 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import type { Content } from '@google/genai'; +import { extractRecentFilePaths } from './postCompactAttachments.js'; + +function fileReadCall(path: string): Content { + return { + role: 'model', + parts: [ + { + functionCall: { + name: 'read_file', + args: { file_path: path }, + }, + }, + ], + }; +} + +function fileWriteCall(path: string): Content { + return { + role: 'model', + parts: [ + { + functionCall: { + name: 'write_file', + args: { file_path: path, content: '...' }, + }, + }, + ], + }; +} + +describe('extractRecentFilePaths', () => { + it('returns the most recently-touched file paths first', () => { + const history: Content[] = [ + fileReadCall('/a.ts'), + fileReadCall('/b.ts'), + fileWriteCall('/c.ts'), + ]; + expect(extractRecentFilePaths(history, 5)).toEqual([ + '/c.ts', + '/b.ts', + '/a.ts', + ]); + }); + + it('deduplicates by file path, keeping the most recent touch', () => { + const history: Content[] = [ + fileReadCall('/a.ts'), + fileReadCall('/b.ts'), + fileWriteCall('/a.ts'), // a.ts is now most recent + ]; + expect(extractRecentFilePaths(history, 5)).toEqual(['/a.ts', '/b.ts']); + }); + + it('respects the maxFiles cap', () => { + const history: Content[] = Array.from({ length: 10 }, (_, i) => + fileReadCall(`/file${i}.ts`), + ); + expect(extractRecentFilePaths(history, 3)).toHaveLength(3); + }); + + it('returns an empty array when no file-touching tool calls exist', () => { + const history: Content[] = [ + { role: 'user', parts: [{ text: 'hello' }] }, + { role: 'model', parts: [{ text: 'hi' }] }, + ]; + expect(extractRecentFilePaths(history, 5)).toEqual([]); + }); + + it('ignores tool calls without a file_path argument', () => { + const history: Content[] = [ + { + role: 'model', + parts: [ + { + functionCall: { name: 'web_fetch', args: { url: 'https://x.com' } }, + }, + ], + }, + fileReadCall('/real.ts'), + ]; + expect(extractRecentFilePaths(history, 5)).toEqual(['/real.ts']); + }); + + it('recognizes edit and replace tools too', () => { + const history: Content[] = [ + { + role: 'model', + parts: [ + { + functionCall: { + name: 'edit', + args: { file_path: '/e.ts', old_string: 'x', new_string: 'y' }, + }, + }, + ], + }, + { + role: 'model', + parts: [ + { functionCall: { name: 'replace', args: { file_path: '/r.ts' } } }, + ], + }, + ]; + const paths = extractRecentFilePaths(history, 5); + expect(paths).toContain('/e.ts'); + expect(paths).toContain('/r.ts'); + }); + + it('returns empty array when maxFiles is 0 or negative', () => { + const history: Content[] = [fileReadCall('/a.ts'), fileReadCall('/b.ts')]; + expect(extractRecentFilePaths(history, 0)).toEqual([]); + expect(extractRecentFilePaths(history, -1)).toEqual([]); + }); + + it('treats parallel tool calls in one content as "last part is newest"', () => { + // Regression: discovered via real-session E2E. A model that issues + // 6 parallel ReadFile calls puts all 6 functionCall parts in ONE + // model+fc content. The previous implementation iterated parts + // forward and filled the cap with the FIRST 5, dropping the + // last-listed file. The cap-of-5 winner set must include the + // LAST 5 (newest) parts when overflow happens. + const history: Content[] = [ + { + role: 'model', + parts: [ + { + functionCall: { name: 'read_file', args: { file_path: '/p1.ts' } }, + }, + { + functionCall: { name: 'read_file', args: { file_path: '/p2.ts' } }, + }, + { + functionCall: { name: 'read_file', args: { file_path: '/p3.ts' } }, + }, + { + functionCall: { name: 'read_file', args: { file_path: '/p4.ts' } }, + }, + { + functionCall: { name: 'read_file', args: { file_path: '/p5.ts' } }, + }, + { + functionCall: { name: 'read_file', args: { file_path: '/p6.ts' } }, + }, + ], + }, + ]; + const paths = extractRecentFilePaths(history, 5); + // Last 5 parts win, returned in newest-first order. + expect(paths).toEqual(['/p6.ts', '/p5.ts', '/p4.ts', '/p3.ts', '/p2.ts']); + expect(paths).not.toContain('/p1.ts'); + }); + + it('excludes paths whose tool call was denied/errored (permission-bypass guard)', () => { + // A denied read_file leaves its functionCall in history with an error + // functionResponse. Restoring that path would read the file off disk + // during compaction, bypassing the denial. The successful read is kept; + // the denied one is dropped. + const history: Content[] = [ + { + role: 'model', + parts: [ + { + functionCall: { + id: 'call_ok', + name: 'read_file', + args: { file_path: '/ws/ok.ts' }, + }, + }, + { + functionCall: { + id: 'call_denied', + name: 'read_file', + args: { file_path: '/ws/.env' }, + }, + }, + ], + }, + { + role: 'user', + parts: [ + { + functionResponse: { + id: 'call_ok', + name: 'read_file', + response: { output: 'export const ok = 1;' }, + }, + }, + { + functionResponse: { + id: 'call_denied', + name: 'read_file', + response: { error: 'Permission denied for tool' }, + }, + }, + ], + }, + ]; + const paths = extractRecentFilePaths(history, 5); + expect(paths).toContain('/ws/ok.ts'); + expect(paths).not.toContain('/ws/.env'); + }); +}); + +import { + countToolResponseImages, + extractRecentImages, +} from './postCompactAttachments.js'; + +function modelCallScreenshot(app: string): Content { + return { + role: 'model', + parts: [ + { + functionCall: { + name: 'computer_use__get_app_state', + args: { app }, + }, + }, + ], + }; +} + +// Mirrors the REAL shape coreToolScheduler.convertToFunctionResponse +// builds: the image is nested inside functionResponse.parts, NOT a +// top-level sibling. (The earlier top-level-sibling fixture never occurs +// in production and masked a bug where extractRecentImages found zero +// screenshots.) +function userToolResultWithImage(mimeType: string, data: string): Content { + return { + role: 'user', + parts: [ + { + functionResponse: { + name: 'computer_use__get_app_state', + response: { output: 'screenshot returned' }, + parts: [{ inlineData: { mimeType, data } }], + } as unknown as NonNullable< + Content['parts'] + >[number]['functionResponse'], + }, + ], + }; +} + +describe('extractRecentImages', () => { + it('returns the last N images in chronological order (oldest first)', () => { + const history: Content[] = [ + modelCallScreenshot('Safari'), + userToolResultWithImage('image/png', 'aaaa'), + modelCallScreenshot('Mail'), + userToolResultWithImage('image/png', 'bbbb'), + modelCallScreenshot('Safari'), + userToolResultWithImage('image/png', 'cccc'), + ]; + const result = extractRecentImages(history, 3); + expect(result.map((r) => r.part.inlineData?.data)).toEqual([ + 'aaaa', + 'bbbb', + 'cccc', + ]); + }); + + it('caps at maxImages by keeping the newest', () => { + const history: Content[] = []; + for (let i = 0; i < 5; i++) { + history.push(modelCallScreenshot(`App${i}`)); + history.push(userToolResultWithImage('image/png', `data${i}`)); + } + const result = extractRecentImages(history, 3); + expect(result.map((r) => r.part.inlineData?.data)).toEqual([ + 'data2', + 'data3', + 'data4', + ]); + }); + + it('captures the preceding model functionCall as metadata', () => { + const history: Content[] = [ + modelCallScreenshot('Safari'), + userToolResultWithImage('image/png', 'aaaa'), + ]; + const result = extractRecentImages(history, 3); + expect(result).toHaveLength(1); + expect(result[0].sourceToolName).toBe('computer_use__get_app_state'); + expect(result[0].sourceToolArgs).toEqual({ app: 'Safari' }); + expect(result[0].turnIndex).toBe(1); // user+fr is at index 1 + }); + + it('also picks up images from user-paste (no preceding model+fc)', () => { + const history: Content[] = [ + { + role: 'user', + parts: [ + { text: 'check this' }, + { inlineData: { mimeType: 'image/png', data: 'pastedimage' } }, + ], + }, + ]; + const result = extractRecentImages(history, 3); + expect(result).toHaveLength(1); + expect(result[0].sourceToolName).toBeUndefined(); + expect(result[0].part.inlineData?.data).toBe('pastedimage'); + }); + + it('ignores non-image inlineData', () => { + const history: Content[] = [ + { + role: 'user', + parts: [ + { inlineData: { mimeType: 'application/pdf', data: 'pdfdata' } }, + ], + }, + ]; + expect(extractRecentImages(history, 3)).toEqual([]); + }); + + it('extracts tool images nested in functionResponse.parts (regression: real screenshot shape)', () => { + // No top-level inlineData anywhere — the image lives ONLY inside + // functionResponse.parts, exactly as convertToFunctionResponse emits. + // The pre-fix extractRecentImages returned [] here. + const history: Content[] = [ + modelCallScreenshot('Safari'), + userToolResultWithImage('image/png', 'nestedshot'), + ]; + const result = extractRecentImages(history, 3); + expect(result).toHaveLength(1); + expect(result[0].part.inlineData?.data).toBe('nestedshot'); + expect(result[0].sourceToolName).toBe('computer_use__get_app_state'); + }); + + it('collects both nested tool images and top-level user pastes', () => { + const history: Content[] = [ + modelCallScreenshot('Safari'), + userToolResultWithImage('image/png', 'toolshot'), + { + role: 'user', + parts: [ + { text: 'and this' }, + { inlineData: { mimeType: 'image/png', data: 'pasted' } }, + ], + }, + ]; + const result = extractRecentImages(history, 3); + expect(result.map((r) => r.part.inlineData?.data)).toEqual([ + 'toolshot', + 'pasted', + ]); + }); +}); + +describe('countToolResponseImages', () => { + it('counts only images nested in functionResponse.parts', () => { + const history: Content[] = [ + modelCallScreenshot('Safari'), + userToolResultWithImage('image/png', 'a'), + modelCallScreenshot('Mail'), + userToolResultWithImage('image/png', 'b'), + ]; + expect(countToolResponseImages(history)).toBe(2); + }); + + it('excludes top-level user-pasted images', () => { + const history: Content[] = [ + { + role: 'user', + parts: [{ inlineData: { mimeType: 'image/png', data: 'pasted' } }], + }, + ]; + expect(countToolResponseImages(history)).toBe(0); + }); + + it('counts multiple images within a single tool result, ignoring non-images', () => { + const history: Content[] = [ + { + role: 'user', + parts: [ + { + functionResponse: { + name: 'computer_use__get_app_state', + response: { output: '' }, + parts: [ + { inlineData: { mimeType: 'image/png', data: 'x' } }, + { inlineData: { mimeType: 'image/jpeg', data: 'y' } }, + { text: 'not an image' }, + { inlineData: { mimeType: 'application/pdf', data: 'doc' } }, + ], + } as unknown as NonNullable< + Content['parts'] + >[number]['functionResponse'], + }, + ], + }, + ]; + expect(countToolResponseImages(history)).toBe(2); + }); + + it('returns 0 for empty history', () => { + expect(countToolResponseImages([])).toBe(0); + }); +}); + +import { readFileSizeAdaptive } from './postCompactAttachments.js'; +import { mkdtempSync, writeFileSync, rmSync, symlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +describe('readFileSizeAdaptive', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'pca-')); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('returns kind=embed with full content when file is under the size cap', async () => { + const path = join(tmpDir, 'small.txt'); + writeFileSync(path, 'hello world', 'utf-8'); + const result = await readFileSizeAdaptive(path, 5_000); + expect(result.kind).toBe('embed'); + if (result.kind === 'embed') { + expect(result.content).toBe('hello world'); + } + }); + + it('returns kind=reference when file exceeds the size cap', async () => { + const path = join(tmpDir, 'big.txt'); + // 5000 tokens × 4 chars = 20000 chars cap; write 30000 chars to exceed + writeFileSync(path, 'x'.repeat(30_000), 'utf-8'); + const result = await readFileSizeAdaptive(path, 5_000); + expect(result.kind).toBe('reference'); + }); + + it('returns kind=missing when the file does not exist', async () => { + const path = join(tmpDir, 'nope.txt'); + const result = await readFileSizeAdaptive(path, 5_000); + expect(result.kind).toBe('missing'); + }); + + it('returns kind=binary when content has too many non-printable bytes', async () => { + const path = join(tmpDir, 'bin.dat'); + const buf = Buffer.alloc(100); + for (let i = 0; i < 100; i++) buf[i] = i % 32; // mostly control bytes + writeFileSync(path, buf); + const result = await readFileSizeAdaptive(path, 5_000); + expect(result.kind).toBe('binary'); + }); + + it('counts CHARACTERS not BYTES for the size cap (UTF-8 multibyte safe)', async () => { + const path = join(tmpDir, 'cjk.txt'); + // 10000 Chinese characters = ~30000 bytes (3 bytes each) but only + // 10000 chars. With maxTokens=5000 (20000 char cap), this should + // embed cleanly. If the implementation counted bytes, it would + // wrongly classify as 'reference'. + const cjkText = '中'.repeat(10_000); + writeFileSync(path, cjkText, 'utf-8'); + const result = await readFileSizeAdaptive(path, 5_000); + expect(result.kind).toBe('embed'); + if (result.kind === 'embed') { + expect(result.content).toBe(cjkText); + expect(result.content.length).toBe(10_000); + } + }); + + it('short-circuits oversized files to reference via stat, before reading (OOM guard)', async () => { + // maxTokens=10 → 40-char cap → 160-byte threshold. Write 4 KB of + // non-printable bytes. The stat pre-check must return 'reference' + // WITHOUT reading; without the pre-check the file would be read and + // binary-detected as 'binary'. Asserting 'reference' (not 'binary') + // proves the pre-check fired and no full read happened. + const path = join(tmpDir, 'huge.bin'); + const buf = Buffer.alloc(4096); + for (let i = 0; i < buf.length; i++) buf[i] = i % 32; // control bytes + writeFileSync(path, buf); + const result = await readFileSizeAdaptive(path, 10); + expect(result.kind).toBe('reference'); + }); +}); + +import { buildFileRestorationBlocks } from './postCompactAttachments.js'; + +describe('buildFileRestorationBlocks', () => { + let tmpDir: string; + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'pca-')); + }); + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('produces an empty array when no files are provided', async () => { + const blocks = await buildFileRestorationBlocks([]); + expect(blocks).toEqual([]); + }); + + it('produces a single user message listing references for all large files', async () => { + const big1 = join(tmpDir, 'big1.txt'); + const big2 = join(tmpDir, 'big2.txt'); + writeFileSync(big1, 'x'.repeat(30_000)); + writeFileSync(big2, 'y'.repeat(30_000)); + + const blocks = await buildFileRestorationBlocks([big1, big2]); + expect(blocks).toHaveLength(1); + expect(blocks[0].role).toBe('user'); + const text = (blocks[0].parts?.[0] as { text?: string }).text ?? ''; + expect(text).toContain(big1); + expect(text).toContain(big2); + expect(text).toContain('reference only'); + // Must instruct the model on how to view the actual content. + expect(text).toMatch(/use.*read_file|call.*read_file/i); + }); + + it('produces one extra user message per embedded small file with its full content', async () => { + const small = join(tmpDir, 'small.txt'); + writeFileSync(small, 'console.log("hi");'); + + const blocks = await buildFileRestorationBlocks([small]); + expect(blocks.length).toBeGreaterThanOrEqual(1); + const embedBlock = blocks.find((b) => + (b.parts?.[0] as { text?: string }).text?.includes('console.log("hi")'), + ); + expect(embedBlock).toBeDefined(); + expect(embedBlock?.role).toBe('user'); + }); + + it('omits the reference block entirely when no large files are present', async () => { + const small = join(tmpDir, 'small.txt'); + writeFileSync(small, 'tiny'); + + const blocks = await buildFileRestorationBlocks([small]); + const allText = blocks + .flatMap((b) => b.parts ?? []) + .map((p) => (p as { text?: string }).text ?? '') + .join('\n'); + expect(allText).not.toMatch(/reference only/i); + }); + + it('skips missing files silently', async () => { + const blocks = await buildFileRestorationBlocks([ + join(tmpDir, 'does-not-exist.txt'), + ]); + expect(blocks).toEqual([]); + }); + + it('respects POST_COMPACT_TOKEN_BUDGET across embedded files', async () => { + // POST_COMPACT_TOKEN_BUDGET (50_000) * CHARS_PER_TOKEN (4) = 200_000 + // char global budget. POST_COMPACT_MAX_TOKENS_PER_FILE (5_000) * + // CHARS_PER_TOKEN (4) = 20_000 char per-file cap. + // + // Create 11 files at exactly the per-file cap (20_000 chars each). + // Total embeddable content = 220_000 chars; budget fits exactly 10 + // (200_000 chars). The 11th must downgrade from embed to reference. + const files: string[] = []; + for (let i = 0; i < 11; i++) { + const p = join(tmpDir, `f${i}.txt`); + writeFileSync( + p, + String.fromCharCode('a'.charCodeAt(0) + i).repeat(20_000), + ); + files.push(p); + } + + const blocks = await buildFileRestorationBlocks(files); + + // The reference block must exist and must mention the 11th file. + const referenceBlock = blocks.find((b) => + (b.parts?.[0] as { text?: string }).text?.includes('reference only'), + ); + expect(referenceBlock).toBeDefined(); + expect((referenceBlock!.parts?.[0] as { text: string }).text).toContain( + files[10], + ); + + // The first 10 files must be embedded (each as its own user message). + for (let i = 0; i < 10; i++) { + const ch = String.fromCharCode('a'.charCodeAt(0) + i); + const expectedSlice = ch.repeat(20_000); + const embedBlock = blocks.find((b) => + (b.parts?.[0] as { text?: string }).text?.includes(expectedSlice), + ); + expect( + embedBlock, + `expected file ${i} (${ch.repeat(3)}...) to be embedded`, + ).toBeDefined(); + } + + // The 11th file must NOT be embedded — it should only appear in the + // reference block. Verify it does not show up in any embed block. + const ch11 = String.fromCharCode('a'.charCodeAt(0) + 10); + const embed11 = blocks.find((b) => { + const text = (b.parts?.[0] as { text?: string }).text ?? ''; + // The reference block contains the path, not the content. An embed + // block would contain a long run of the file's content characters. + return text.includes(ch11.repeat(20_000)); + }); + expect(embed11).toBeUndefined(); + }); + + it('uses a longer fence when file content contains triple backticks', async () => { + const path = join(tmpDir, 'with-backticks.md'); + // File whose content contains a triple-backtick run — would close + // a 3-backtick fence prematurely with the old implementation. + const content = + '# Heading\n\nSome text\n```ts\nconst x = 1;\n```\n\nMore text.'; + writeFileSync(path, content); + + const blocks = await buildFileRestorationBlocks([path]); + expect(blocks).toHaveLength(1); + const text = (blocks[0].parts?.[0] as { text: string }).text; + // The fence must be 4+ backticks long since content has a 3-backtick run. + expect(text).toMatch(/````\n.*const x = 1;.*\n````/s); + // The file content (including the inner ```ts) appears intact. + expect(text).toContain('```ts\nconst x = 1;\n```'); + expect(text).toContain('More text.'); + }); + + it('strips control characters from displayed file paths', async () => { + // Construct a path that exists on disk but whose string representation + // (in attachment text) should be sanitized. We can't easily put a real + // newline in a filename, so we use a path with a tab — also stripped. + // The actual file isn't read (we test reference block path only). + // To do this without real file shenanigans, test the helper indirectly: + // pass a non-existent path that contains \n in its string. It should + // be classified as 'missing' by readFileSizeAdaptive, skipped silently — + // BUT if the path is large enough to be a reference (i.e. exists), it + // would render sanitized. We bypass real-fs sensitivity by checking + // the reference output for a real file with normal name and asserting + // the rendering goes through `sanitizePathForDisplay`. Since we can't + // easily inject \n into a real path, we assert the behavior of the + // helper directly via an indirect test: confirm a known-normal path + // renders without modification. + const normal = join(tmpDir, 'normal-file.ts'); + writeFileSync(normal, 'x'.repeat(30_000)); // force reference branch + const blocks = await buildFileRestorationBlocks([normal]); + const refText = (blocks[0].parts?.[0] as { text: string }).text; + expect(refText).toContain(normal); // sanitization is identity for clean paths + }); +}); + +import { + buildImageRestorationBlock, + type ExtractedImage, +} from './postCompactAttachments.js'; + +describe('buildImageRestorationBlock', () => { + it('returns null when no images are provided', () => { + expect(buildImageRestorationBlock([])).toBeNull(); + }); + + it('emits a single user Content with metadata header + image parts', () => { + const images: ExtractedImage[] = [ + { + part: { inlineData: { mimeType: 'image/png', data: 'aaaa' } }, + turnIndex: 5, + sourceToolName: 'computer_use__get_app_state', + sourceToolArgs: { app: 'Safari' }, + }, + { + part: { inlineData: { mimeType: 'image/png', data: 'bbbb' } }, + turnIndex: 11, + sourceToolName: 'computer_use__get_app_state', + sourceToolArgs: { app: 'Mail' }, + }, + ]; + const block = buildImageRestorationBlock(images); + expect(block).not.toBeNull(); + expect(block!.role).toBe('user'); + expect(block!.parts).toHaveLength(3); // 1 text header + 2 images + + const header = (block!.parts![0] as { text: string }).text; + expect(header).toContain('Recent visual snapshots'); + expect(header).toContain('turn 5'); + expect(header).toContain('computer_use__get_app_state'); + expect(header).toContain('"app":"Safari"'); + expect(header).toContain('turn 11'); + expect(header).toContain('"app":"Mail"'); + + expect(block!.parts![1].inlineData?.data).toBe('aaaa'); + expect(block!.parts![2].inlineData?.data).toBe('bbbb'); + }); + + it('handles images without source-tool metadata (user paste)', () => { + const images: ExtractedImage[] = [ + { + part: { inlineData: { mimeType: 'image/png', data: 'pasted' } }, + turnIndex: 3, + }, + ]; + const block = buildImageRestorationBlock(images); + const header = (block!.parts![0] as { text: string }).text; + expect(header).toContain('turn 3'); + expect(header).toContain('user-provided'); // labeled instead of tool name + }); +}); + +import { + composePostCompactHistory, + postProcessSummary, +} from './postCompactAttachments.js'; + +describe('composePostCompactHistory', () => { + let tmpDir: string; + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'pca-')); + }); + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('returns summary + ack only when history has no files or images', async () => { + const history: Content[] = [ + { role: 'user', parts: [{ text: 'hi' }] }, + { role: 'model', parts: [{ text: 'hello' }] }, + ]; + const result = await composePostCompactHistory(history, 'SUMMARY_TEXT'); + expect(result).toHaveLength(2); + expect(result[0].role).toBe('user'); + expect((result[0].parts?.[0] as { text: string }).text).toContain( + 'SUMMARY_TEXT', + ); + expect(result[1].role).toBe('model'); + }); + + it('orders sections as: summary → file refs → file embeds → images', async () => { + const small = join(tmpDir, 'cfg.json'); + writeFileSync(small, '{"a":1}'); + const big = join(tmpDir, 'big.txt'); + writeFileSync(big, 'x'.repeat(30_000)); + + const history: Content[] = [ + { + role: 'model', + parts: [ + { functionCall: { name: 'read_file', args: { file_path: small } } }, + ], + }, + { + role: 'model', + parts: [ + { functionCall: { name: 'read_file', args: { file_path: big } } }, + ], + }, + { + role: 'model', + parts: [ + { + functionCall: { + name: 'computer_use__get_app_state', + args: { app: 'Safari' }, + }, + }, + ], + }, + { + role: 'user', + parts: [ + { + functionResponse: { + name: 'computer_use__get_app_state', + response: { output: 'screenshot' }, + }, + }, + { inlineData: { mimeType: 'image/png', data: 'shot' } }, + ], + }, + ]; + + const result = await composePostCompactHistory(history, 'SUM'); + + // Section markers we expect, in order: + const flatText = result + .flatMap((c) => c.parts ?? []) + .map((p) => (p as { text?: string }).text ?? '') + .join('\n---\n'); + + const idxSummary = flatText.indexOf('SUM'); + const idxRefs = flatText.indexOf('reference only'); + const idxEmbed = flatText.indexOf('cfg.json'); + const idxImage = flatText.indexOf('Recent visual snapshots'); + + expect(idxSummary).toBeGreaterThanOrEqual(0); + expect(idxRefs).toBeGreaterThan(idxSummary); + expect(idxEmbed).toBeGreaterThan(idxRefs); + expect(idxImage).toBeGreaterThan(idxEmbed); + }); + + it('includes a model ack message after the summary so role alternates correctly', async () => { + const history: Content[] = [{ role: 'user', parts: [{ text: 'do x' }] }]; + const result = await composePostCompactHistory(history, 'SUM'); + // First two entries must be user (summary), then model (ack). + expect(result[0].role).toBe('user'); + expect(result[1].role).toBe('model'); + expect((result[1].parts?.[0] as { text: string }).text).toMatch( + /got it|acknowledged|continue/i, + ); + }); + + it('emits role-alternating history with multiple file/image attachments merged into a single user Content (Finding 2)', async () => { + // Regression: prior implementation pushed each file restoration block + // as its own user Content, producing consecutive user roles which + // violates geminiChat.test.ts:6289 strict-alternation assertion and + // is rejected by Gemini API with "consecutive same-role content". + const small = join(tmpDir, 'a.ts'); + writeFileSync(small, 'export const a = 1;'); + const small2 = join(tmpDir, 'b.ts'); + writeFileSync(small2, 'export const b = 2;'); + const big = join(tmpDir, 'big.ts'); + writeFileSync(big, 'x'.repeat(30_000)); + + const history: Content[] = [ + { + role: 'model', + parts: [ + { functionCall: { name: 'read_file', args: { file_path: small } } }, + { functionCall: { name: 'read_file', args: { file_path: small2 } } }, + { functionCall: { name: 'read_file', args: { file_path: big } } }, + ], + }, + { + role: 'user', + parts: [{ inlineData: { mimeType: 'image/png', data: 'shot' } }], + }, + ]; + + const result = await composePostCompactHistory(history, 'SUM'); + // Strict alternation: no two adjacent entries share a role. + for (let i = 1; i < result.length; i++) { + expect(result[i].role).not.toBe(result[i - 1].role); + } + }); + + it('preserves a trailing model+functionCall so a pending functionResponse has its match (Finding 3)', async () => { + // Regression: the old split-point fallback explicitly retained + // trailing model+functionCall so that a pending functionResponse + // (sitting in sendMessageStream's pendingUserMessage) had a + // matching call. The full-history rewrite dropped the entire + // history including that call, producing user+functionResponse + // with no preceding model+functionCall → API 400. + const history: Content[] = [ + { role: 'user', parts: [{ text: 'use the tool' }] }, + { + role: 'model', + parts: [ + { + functionCall: { + name: 'read_file', + args: { file_path: '/some/file.ts' }, + }, + }, + ], + }, + ]; + const result = await composePostCompactHistory(history, 'SUM'); + // SOMEWHERE in the output the trailing functionCall must survive + // so that a pending functionResponse has its match. + const hasTrailingFuncCall = result.some( + (c) => c.role === 'model' && c.parts?.some((p) => !!p.functionCall), + ); + expect(hasTrailingFuncCall).toBe(true); + // And strict alternation must still hold. + for (let i = 1; i < result.length; i++) { + expect(result[i].role).not.toBe(result[i - 1].role); + } + // The very last entry must be the model funcCall (so the next + // appended user+functionResponse pairs with it). + const last = result[result.length - 1]; + expect(last.role).toBe('model'); + expect(last.parts?.some((p) => !!p.functionCall)).toBe(true); + }); + + it('attachments + trailing functionCall produce a 4-entry, role-alternating output', async () => { + // The most complex branch: postAckParts.length > 0 AND a trailing + // model+functionCall, producing [user(summary), model(ack), + // user(attachments), model(fc)]. The prior trailing-fc test hits the + // 2-entry fold (no attachments); the cap tests hit the 3-entry shape + // (no trailing fc). This is the common production case — auto-compaction + // mid-tool-loop after the agent read files AND has an in-flight call — + // and a model→model adjacency here is a 400 from the provider. + const realFile = join(tmpDir, 'real.ts'); + writeFileSync(realFile, 'export const x = 1;'); + const history: Content[] = [ + { role: 'user', parts: [{ text: 'read it' }] }, + { + role: 'model', + parts: [ + { + functionCall: { name: 'read_file', args: { file_path: realFile } }, + }, + ], + }, + { role: 'user', parts: [{ text: 'ok' }] }, + { + role: 'model', + parts: [ + { text: 'editing' }, + { functionCall: { name: 'edit', args: { file_path: realFile } } }, + ], + }, + ]; + const result = await composePostCompactHistory(history, 'SUM', { + workspaceRoot: tmpDir, + }); + expect(result).toHaveLength(4); + for (let i = 1; i < result.length; i++) { + expect(result[i].role).not.toBe(result[i - 1].role); + } + const last = result[result.length - 1]; + expect(last.role).toBe('model'); + expect(last.parts?.some((p) => !!p.functionCall)).toBe(true); + // The embedded file attachment is present, confirming postAckParts > 0 + // (i.e. we really took the 4-entry branch, not the 2-entry fold). + const allText = result + .flatMap((c) => c.parts ?? []) + .map((p) => (p as { text?: string }).text ?? '') + .join('\n'); + expect(allText).toContain('export const x = 1;'); + }); + + it('skips files outside the workspace root (Finding 4)', async () => { + // Security: extractRecentFilePaths collects ALL functionCall paths + // including model attempts at /etc/passwd that the permission + // system already denied. readFileSizeAdaptive would happily read + // those off disk. Filter at the composer with a workspace boundary. + const inside = join(tmpDir, 'inside.ts'); + writeFileSync(inside, 'export const inside = true;'); + const outside = '/etc/hosts'; // exists on every system; outside tmpDir + + const history: Content[] = [ + { + role: 'model', + parts: [ + { + functionCall: { name: 'read_file', args: { file_path: outside } }, + }, + { + functionCall: { name: 'read_file', args: { file_path: inside } }, + }, + ], + }, + ]; + + const result = await composePostCompactHistory(history, 'SUM', { + workspaceRoot: tmpDir, + }); + const allText = result + .flatMap((c) => c.parts ?? []) + .map((p) => (p as { text?: string }).text ?? '') + .join('\n'); + expect(allText).toContain(inside); + expect(allText).not.toContain('/etc/hosts'); + }); + + it('rejects a symlink inside the workspace that points outside it', async () => { + // Security: a symlink LIVING in the workspace but pointing OUTSIDE + // (e.g. workspace/.env -> ~/.ssh/id_rsa) passes a lexical boundary + // check but must be rejected — realpath resolution catches it. + const outsideDir = mkdtempSync(join(tmpdir(), 'pca-outside-')); + const secret = join(outsideDir, 'secret.txt'); + writeFileSync(secret, 'TOP_SECRET_CONTENT'); + const link = join(tmpDir, 'innocent.ts'); + symlinkSync(secret, link); // workspace/innocent.ts -> outsideDir/secret.txt + + const history: Content[] = [ + { + role: 'model', + parts: [ + { functionCall: { name: 'read_file', args: { file_path: link } } }, + ], + }, + ]; + const result = await composePostCompactHistory(history, 'SUM', { + workspaceRoot: tmpDir, + }); + const allText = result + .flatMap((c) => c.parts ?? []) + .map((p) => (p as { text?: string }).text ?? '') + .join('\n'); + // Lexical resolve would embed the secret; realpath rejects the link. + expect(allText).not.toContain('TOP_SECRET_CONTENT'); + rmSync(outsideDir, { recursive: true, force: true }); + }); + + it('honors AbortSignal — does not invoke file reads after abort (Finding 5)', async () => { + const small = join(tmpDir, 'small.ts'); + writeFileSync(small, 'tiny'); + + const history: Content[] = [ + { + role: 'model', + parts: [ + { + functionCall: { name: 'read_file', args: { file_path: small } }, + }, + ], + }, + ]; + + const ctrl = new AbortController(); + ctrl.abort(); + // Should reject with AbortError (or similar) — readFileSizeAdaptive + // must observe the signal. We accept either rejection OR a clean + // empty restoration (no file content embedded), depending on where + // the signal check fires. The contract: NEVER embed file content + // after abort. + let result: Content[] = []; + let threw = false; + try { + result = await composePostCompactHistory(history, 'SUM', { + signal: ctrl.signal, + }); + } catch { + threw = true; + } + if (!threw) { + const allText = result + .flatMap((c) => c.parts ?? []) + .map((p) => (p as { text?: string }).text ?? '') + .join('\n'); + // Aborted before reading: file path / content must not appear in + // an embed block. (A bare reference is fine — that's just the path.) + expect(allText).not.toContain('tiny'); + } + }); + + it('strips the block from the raw summary before placing it in newHistory', async () => { + const history: Content[] = [{ role: 'user', parts: [{ text: 'do x' }] }]; + const raw = + '\nthe model was thinking out loud here\nshould not leak\n\n\n\n actual summary\n'; + const result = await composePostCompactHistory(history, raw); + const summaryText = (result[0].parts?.[0] as { text: string }).text; + expect(summaryText).not.toContain(''); + expect(summaryText).not.toContain('thinking out loud'); + expect(summaryText).toContain(''); + expect(summaryText).toContain('actual summary'); + }); + + it('appends the resume trailer to the summary message text', async () => { + const history: Content[] = [{ role: 'user', parts: [{ text: 'do x' }] }]; + const result = await composePostCompactHistory( + history, + '...', + ); + const summaryText = (result[0].parts?.[0] as { text: string }).text; + // Trailer instructs the resuming agent not to greet / recap. + expect(summaryText).toMatch(/resume.*prior task|continue from/i); + expect(summaryText).toMatch( + /do not acknowledge|do not re-introduce|do not greet/i, + ); + }); + + it('respects maxFiles / maxImages caps from options', async () => { + const f1 = join(tmpDir, 'one.ts'); + const f2 = join(tmpDir, 'two.ts'); + writeFileSync(f1, 'export const one = 1;'); + writeFileSync(f2, 'export const two = 2;'); + + const history: Content[] = [ + { + role: 'model', + parts: [ + { functionCall: { name: 'read_file', args: { file_path: f1 } } }, + { functionCall: { name: 'read_file', args: { file_path: f2 } } }, + ], + }, + { + role: 'user', + parts: [ + { + functionResponse: { + name: 'computer_use__get_app_state', + response: { output: '' }, + parts: [ + { inlineData: { mimeType: 'image/png', data: 'img1' } }, + { inlineData: { mimeType: 'image/png', data: 'img2' } }, + ], + } as unknown as NonNullable< + Content['parts'] + >[number]['functionResponse'], + }, + ], + }, + ]; + + const result = await composePostCompactHistory(history, 'SUM', { + workspaceRoot: tmpDir, + maxFiles: 1, + maxImages: 1, + }); + + const inlineImages = result + .flatMap((c) => c.parts ?? []) + .filter((p) => (p as { inlineData?: unknown }).inlineData); + expect(inlineImages).toHaveLength(1); + + const allText = result + .flatMap((c) => c.parts ?? []) + .map((p) => (p as { text?: string }).text ?? '') + .join('\n'); + // Parallel calls in one model turn: the last (two.ts) is most recent, + // so the single retained file is two.ts; one.ts is dropped. Assert on + // embedded CONTENT, not the path — the path can also surface in image + // attribution metadata, which isn't what this cap controls. + expect(allText).toContain('export const two = 2;'); + expect(allText).not.toContain('export const one = 1;'); + }); + + it('restores no attachments when maxFiles and maxImages are 0', async () => { + const f1 = join(tmpDir, 'z.ts'); + writeFileSync(f1, 'export const z = 1;'); + const history: Content[] = [ + { + role: 'model', + parts: [ + { functionCall: { name: 'read_file', args: { file_path: f1 } } }, + ], + }, + { + role: 'user', + parts: [ + { + functionResponse: { + name: 'computer_use__get_app_state', + response: { output: '' }, + parts: [{ inlineData: { mimeType: 'image/png', data: 'i' } }], + } as unknown as NonNullable< + Content['parts'] + >[number]['functionResponse'], + }, + ], + }, + ]; + const result = await composePostCompactHistory(history, 'SUM', { + workspaceRoot: tmpDir, + maxFiles: 0, + maxImages: 0, + }); + // Only [summary(user), ack(model)] — no attachment Content appended. + expect(result).toHaveLength(2); + expect(result[0].role).toBe('user'); + expect(result[1].role).toBe('model'); + }); + + it('output is not re-counted by the screenshot trigger (restored images are top-level)', async () => { + // The screenshot trigger counts only images nested in + // functionResponse.parts. composePostCompactHistory re-embeds surviving + // images as TOP-LEVEL parts, so countToolResponseImages() on its output + // must be 0 — otherwise a freshly compacted history could immediately + // re-trigger compaction. Locks in the no-loop invariant. + const history: Content[] = [ + modelCallScreenshot('Safari'), + userToolResultWithImage('image/png', 'a'), + modelCallScreenshot('Mail'), + userToolResultWithImage('image/png', 'b'), + ]; + const result = await composePostCompactHistory(history, 'SUM', { + maxImages: 5, + }); + const restoredImages = result + .flatMap((c) => c.parts ?? []) + .filter((p) => (p as { inlineData?: unknown }).inlineData); + expect(restoredImages.length).toBeGreaterThan(0); // images survived... + expect(countToolResponseImages(result)).toBe(0); // ...but top-level, uncounted + }); +}); + +describe('postProcessSummary', () => { + it('returns body + trailer when no block is present', () => { + const out = postProcessSummary('body'); + expect(out).toContain('body'); + expect(out).toMatch(/resume.*prior task/i); + }); + + it('strips wrappers (greedy across newlines)', () => { + const out = postProcessSummary( + '\nlots of\nmulti-line\nreasoning\n\n\nbody', + ); + expect(out).not.toContain(''); + expect(out).not.toContain('multi-line'); + expect(out).toContain('body'); + }); + + it('strips multiple blocks if the model emits more than one', () => { + const out = postProcessSummary( + 'first\nbody\nsecond', + ); + expect(out).not.toContain(''); + expect(out).not.toContain('first'); + expect(out).not.toContain('second'); + }); + + it('does NOT re-inject the body when the model emits only scratchpad (Finding 6)', () => { + // Regression: prior implementation fell back to `rawSummary.trim()` + // which re-injected the entire block when strip left + // an empty result. The whole point of the strip is to keep the + // scratchpad out of the next agent's context. + const raw = 'nothing else'; + const out = postProcessSummary(raw); + expect(out).not.toContain(''); + expect(out).not.toContain('nothing else'); + // Trailer must still be appended so the resuming agent gets clear + // continuation guidance even when the summary body is missing. + expect(out).toMatch(/resume.*prior task/i); + }); + + it('strips an unclosed block in the fallback path (Finding 6)', () => { + // Pathological: model emits tag but never closes it. + // The closed-tag regex misses → stripped non-empty → no fallback. + // The unclosed-tag fallback regex must catch this case so the + // body never leaks into history. + const raw = 'still thinking about the answer'; + const out = postProcessSummary(raw); + expect(out).not.toContain(''); + expect(out).not.toContain('still thinking'); + expect(out).toMatch(/resume.*prior task/i); + }); +}); diff --git a/packages/core/src/services/postCompactAttachments.ts b/packages/core/src/services/postCompactAttachments.ts new file mode 100644 index 0000000000..5011e17c06 --- /dev/null +++ b/packages/core/src/services/postCompactAttachments.ts @@ -0,0 +1,700 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * postCompactAttachments — pure builders for the message blocks injected + * AFTER the summary in a compacted history. Replaces qwen-code's tail- + * preservation model (split-point + last 30%) with claude-code's + * "summary + restored attachments" model. + * + * Everything in this module is message-history-driven: no separate state + * caches, no new message types. Extractors walk `Content[]`, builders + * produce ordinary user/model `Content` objects with text/inlineData parts. + */ + +import type { Content, Part } from '@google/genai'; +import { readFile, stat } from 'node:fs/promises'; +import { realpathSync } from 'node:fs'; +import { resolve as resolvePath, sep as pathSep } from 'node:path'; +import { CHARS_PER_TOKEN } from './tokenEstimation.js'; +import { getFunctionResponseParts } from './compactionInputSlimming.js'; + +export const POST_COMPACT_MAX_FILES_TO_RESTORE = 5; + +/** + * Find the longest run of consecutive backticks in `s`. Used to choose + * a CommonMark-safe fence: a fence one backtick longer than any run + * inside the fenced content cannot be closed prematurely. + */ +function longestBacktickRun(s: string): number { + let longest = 0; + let current = 0; + for (const ch of s) { + if (ch === '`') { + current += 1; + if (current > longest) longest = current; + } else { + current = 0; + } + } + return longest; +} + +/** + * Strip control characters from a path before rendering it into an + * attachment's markdown text. The path itself stays usable for tool + * calls (we just don't print the dangerous characters). A path with a + * literal newline could otherwise inject markdown structure into the + * model's view of the attachment. + */ +function sanitizePathForDisplay(path: string): string { + return path.replace(/[\r\n\t]/g, ''); +} +export const POST_COMPACT_MAX_TOKENS_PER_FILE = 5_000; +export const POST_COMPACT_TOKEN_BUDGET = 50_000; +export const POST_COMPACT_MAX_IMAGES_TO_RESTORE = 3; + +/** Tool names that signal "this turn touched a file at args.file_path". */ +const FILE_TOUCHING_TOOLS = new Set([ + 'read_file', + 'write_file', + 'edit', + 'replace', // legacy alias for 'edit' — may appear in old sessions (see ToolNamesMigration) +]); + +/** + * Collect the ids of tool calls whose `functionResponse` reported an error + * (`response.error` present) — denied, cancelled, or otherwise failed. A + * successful call carries `response.output` and no `error`, so it is not + * collected. Used to keep denied/failed file reads out of restoration. + */ +function collectFailedCallIds(history: Content[]): Set { + const failed = new Set(); + for (const content of history) { + for (const part of content.parts ?? []) { + const fr = part.functionResponse as + | { id?: string; response?: Record } + | undefined; + if (fr?.id && fr.response && 'error' in fr.response) { + failed.add(fr.id); + } + } + } + return failed; +} + +/** + * Walk the history newest-first, collect the most recently touched file + * paths, deduplicated. Older mentions of the same path are dropped in + * favor of the most recent one. + */ +export function extractRecentFilePaths( + history: Content[], + maxFiles: number, +): string[] { + if (maxFiles <= 0) return []; + + // A denied / errored tool call still leaves its `functionCall` in history, + // paired with an error `functionResponse` (`response.error`). Restoring + // such a path would read the file straight off disk during compaction — + // bypassing the very permission the call was denied. Collect those failed + // call ids so we can skip them: never re-read a file the agent didn't + // successfully read. + const failedCallIds = collectFailedCallIds(history); + + const seen = new Set(); + for (let i = history.length - 1; i >= 0; i--) { + const content = history[i]; + if (content.role !== 'model') continue; + // Iterate parts in REVERSE within a single content so parallel tool + // calls (multiple functionCall parts in one model turn) are treated + // as "the last call is the most recent". Forward iteration here would + // pick the FIRST 5 of a 6-parallel batch, dropping the actually-most- + // recent call — discovered via real-session E2E. + const parts = content.parts ?? []; + for (let j = parts.length - 1; j >= 0; j--) { + const part = parts[j]; + const call = part.functionCall; + if (!call || !FILE_TOUCHING_TOOLS.has(call.name ?? '')) continue; + // Skip paths whose tool call failed (denied / errored). + if (call.id && failedCallIds.has(call.id)) continue; + const args = call.args as { file_path?: unknown } | undefined; + const filePath = + typeof args?.file_path === 'string' ? args.file_path : undefined; + if (!filePath || seen.has(filePath)) continue; + seen.add(filePath); + if (seen.size >= maxFiles) return [...seen]; + } + } + return [...seen]; +} + +export interface ExtractedImage { + /** The original `inlineData` part, ready to embed verbatim. */ + part: Part; + /** Turn index in the original history (for metadata header). */ + turnIndex: number; + /** Name of the tool whose call immediately preceded this image, if any. */ + sourceToolName?: string; + /** Args of that tool call, for the metadata header. */ + sourceToolArgs?: Record; +} + +/** + * Walk a single content's parts in REVERSE and return every image part + * it carries — both top-level `inlineData` (user-pasted images) and + * images nested inside `functionResponse.parts` (qwen-code's tool-media + * carrier; see coreToolScheduler.convertToFunctionResponse). Reverse + * order means the last-emitted image is treated as the most recent. + * + * Walking only the top-level shape — as this module originally did — + * silently drops every tool-returned screenshot, because + * `convertToFunctionResponse` ALWAYS nests tool media under + * `functionResponse.parts` and never at the top level. That made the + * whole screenshot-restoration feature a no-op for real computer-use + * sessions while the unit tests (which fabricated a top-level shape) + * stayed green. + */ +function imagePartsInContentReverse(content: Content): Part[] { + const result: Part[] = []; + const parts = content.parts ?? []; + for (let j = parts.length - 1; j >= 0; j--) { + const part = parts[j]; + if (part.inlineData?.mimeType?.startsWith('image/')) { + result.push(part); + continue; + } + const nested = getFunctionResponseParts(part); + if (nested) { + for (let k = nested.length - 1; k >= 0; k--) { + const inner = nested[k]; + if (inner.inlineData?.mimeType?.startsWith('image/')) { + result.push(inner); + } + } + } + } + return result; +} + +/** + * Walk the history newest-first, collect up to `maxImages` image parts + * (top-level user-pasted images AND tool-returned images nested in + * `functionResponse.parts`), and pair each with the preceding + * model+functionCall (if any) as source-tool metadata. + * + * Returns oldest-first so callers can compose a chronological strip + * (last user-visible state ends up at the bottom of the attachment). + */ +export function extractRecentImages( + history: Content[], + maxImages: number, +): ExtractedImage[] { + if (maxImages <= 0) return []; + + const collected: ExtractedImage[] = []; + + outer: for (let i = history.length - 1; i >= 0; i--) { + const content = history[i]; + const imageParts = imagePartsInContentReverse(content); + if (imageParts.length === 0) continue; + + // Attribute via the most recent model+functionCall sitting at i-1 + // (the typical (model+fc, user+fr) pair shape). Shared across every + // image in this turn — for parallel tool calls this attributes all + // images to the first call, an accepted simplification. + let sourceToolName: string | undefined; + let sourceToolArgs: Record | undefined; + const prev = history[i - 1]; + if (prev?.role === 'model') { + const fc = prev.parts?.find((p) => p.functionCall)?.functionCall; + if (fc) { + sourceToolName = fc.name ?? undefined; + sourceToolArgs = + (fc.args as Record | undefined) ?? undefined; + } + } + + for (const part of imageParts) { + collected.unshift({ part, turnIndex: i, sourceToolName, sourceToolArgs }); + if (collected.length >= maxImages) break outer; + } + } + + return collected; +} + +/** + * Count images RETURNED BY TOOLS across the whole history — inlineData + * image parts nested inside `functionResponse.parts`. User-pasted + * top-level images are intentionally excluded: this drives the + * computer-use screenshot-overflow auto-compact trigger, whose concern + * is screenshot accumulation from tool results, not occasional pastes. + */ +export function countToolResponseImages(history: Content[]): number { + let count = 0; + for (const content of history) { + for (const part of content.parts ?? []) { + const nested = getFunctionResponseParts(part); + if (!nested) continue; + for (const inner of nested) { + if (inner.inlineData?.mimeType?.startsWith('image/')) count++; + } + } + } + return count; +} + +export type FileEmbedResult = + | { kind: 'embed'; content: string } + | { kind: 'reference' } + | { kind: 'missing' } + | { kind: 'binary' }; + +const BINARY_DETECT_SAMPLE = 512; +const BINARY_NONPRINTABLE_THRESHOLD = 0.3; + +/** + * Read a file from disk and decide whether to embed its full content + * (small files, ≤ maxTokens × CHARS_PER_TOKEN) or only return a path + * reference (large files; the agent must call read_file to view them). + * + * Returns 'missing' if the file no longer exists (deleted between when + * it was last touched and compaction time), 'binary' if it appears to + * contain non-text data. + */ +export async function readFileSizeAdaptive( + filePath: string, + maxTokens: number, + signal?: AbortSignal, +): Promise { + // Honor abort BEFORE issuing the I/O so a cancelled compaction does not + // even start the read (per Finding 5). + if (signal?.aborted) return { kind: 'missing' }; + + const maxChars = maxTokens * CHARS_PER_TOKEN; + // Byte-size pre-check: avoid loading a multi-GB file into a Buffer just to + // discover it's too large — that would exhaust the V8 heap mid-compaction, + // exactly when we're trying to REDUCE memory. UTF-8 is at most 4 bytes per + // char, so any file whose byte size exceeds maxChars*4 cannot fit within + // maxChars chars; short-circuit it to a reference without reading it. + try { + const { size } = await stat(filePath); + if (size > maxChars * 4) return { kind: 'reference' }; + } catch { + // ENOENT / permission / etc. — treat as missing, same as the read path. + return { kind: 'missing' }; + } + + let buffer: Buffer; + try { + buffer = await readFile(filePath, { signal }); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return { kind: 'missing' }; + } + if ((err as { name?: string }).name === 'AbortError') { + return { kind: 'missing' }; + } + // Permission errors, IO errors, etc. — treat as missing for the + // purpose of compaction. The agent can still retry via read_file + // and get a real error there if it's load-bearing. + return { kind: 'missing' }; + } + + // Binary detection on first BINARY_DETECT_SAMPLE bytes. Counts + // bytes outside printable ASCII + common whitespace as suspicious. + const sample = buffer.subarray( + 0, + Math.min(buffer.length, BINARY_DETECT_SAMPLE), + ); + let nonPrintable = 0; + for (const byte of sample) { + const printable = + (byte >= 0x20 && byte <= 0x7e) || // ASCII printable + byte === 0x09 || // tab + byte === 0x0a || // LF + byte === 0x0d || // CR + byte >= 0x80; // utf-8 continuation bytes — treat as printable + if (!printable) nonPrintable++; + } + if ( + sample.length > 0 && + nonPrintable / sample.length > BINARY_NONPRINTABLE_THRESHOLD + ) { + return { kind: 'binary' }; + } + + // Decode once and compare against the cap by character length, not + // byte length. A 3-byte UTF-8 character (e.g. Chinese) would otherwise + // be triple-counted against the budget. The decoded value is reused + // for the embed branch so this costs nothing extra. + const decoded = buffer.toString('utf-8'); + if (decoded.length > maxChars) { + return { kind: 'reference' }; + } + + return { kind: 'embed', content: decoded }; +} + +/** + * Compose the file-restoration section of a post-compact history. Reads + * each file from disk, classifies as embed/reference/missing/binary, and + * produces: + * - One reference block listing all large files (path only), if any. + * - One embed block per small file with full content. + * - Nothing for missing/binary files. + * + * Total embedded chars are capped at POST_COMPACT_TOKEN_BUDGET × + * CHARS_PER_TOKEN. Files that would push over the budget are downgraded + * to references. + */ +export async function buildFileRestorationBlocks( + filePaths: string[], + signal?: AbortSignal, +): Promise { + const references: string[] = []; + const embeds: Array<{ path: string; content: string }> = []; + + let usedChars = 0; + const budgetChars = POST_COMPACT_TOKEN_BUDGET * CHARS_PER_TOKEN; + + for (const filePath of filePaths) { + if (signal?.aborted) break; + const result = await readFileSizeAdaptive( + filePath, + POST_COMPACT_MAX_TOKENS_PER_FILE, + signal, + ); + if (result.kind === 'missing' || result.kind === 'binary') continue; + if (result.kind === 'reference') { + references.push(filePath); + continue; + } + // embed — check global budget; downgrade to reference if over. + if (usedChars + result.content.length > budgetChars) { + references.push(filePath); + continue; + } + embeds.push({ path: filePath, content: result.content }); + usedChars += result.content.length; + } + + const blocks: Content[] = []; + + if (references.length > 0) { + const lines = [ + 'The following files were recently accessed before context was compacted. They are listed as reference only because they are large. Use `read_file` to view current content for any file you need:', + '', + ...references.map((p) => `- ${sanitizePathForDisplay(p)}`), + ]; + blocks.push({ + role: 'user', + parts: [{ text: lines.join('\n') }], + }); + } + + for (const { path, content } of embeds) { + // CommonMark-safe fence: use a backtick run that is one longer than + // the longest run already in the content. Markdown/CLAUDE.md/README + // files frequently contain ``` themselves; a fixed 3-backtick fence + // closes prematurely and leaks the remainder as unfenced text. + const fence = '`'.repeat(longestBacktickRun(content) + 1); + const safeFence = fence.length >= 3 ? fence : '```'; + blocks.push({ + role: 'user', + parts: [ + { + text: + `Recently accessed file (full current content embedded):\n\n` + + `## ${sanitizePathForDisplay(path)}\n\n` + + safeFence + + '\n' + + content + + '\n' + + safeFence, + }, + ], + }); + } + + return blocks; +} + +/** + * Compose the image-restoration block: a single user Content whose first + * part is a text header listing each image's source (turn index + tool + * call + args), followed by the inlineData parts in chronological order. + * + * Returns null if there are no images so callers can skip it cleanly. + */ +export function buildImageRestorationBlock( + images: ExtractedImage[], +): Content | null { + if (images.length === 0) return null; + + const lines = [ + 'Recent visual snapshots preserved from before context was compacted (most recent last). Each image corresponds to a tool result or user-pasted image earlier in the conversation:', + '', + ]; + for (const img of images) { + if (img.sourceToolName) { + const argsStr = JSON.stringify(img.sourceToolArgs ?? {}); + lines.push( + `- turn ${img.turnIndex}: ${img.sourceToolName} args=${argsStr}`, + ); + } else { + lines.push(`- turn ${img.turnIndex}: user-provided image`); + } + } + + return { + role: 'user', + parts: [{ text: lines.join('\n') }, ...images.map((img) => img.part)], + }; +} + +/** + * Assemble the complete post-compact history from the pre-compact + * `history` and the summary text the side-query model produced. + * + * Output ordering: + * 1. Summary as a user message (the side-query output) + * 2. Synthetic model ack ("Got it. Thanks for the additional context.") + * 3. File reference block (path-only list of large files), if any + * 4. Per-embedded-file user message with full content + * 5. Image restoration block, if any + * + * The ack message keeps role alternation correct: the next API call will + * naturally append the model's continuation response. + */ +/** + * Trailer appended to the post-compact summary message. Mirrors claude-code's + * "Resume directly" guidance for the resuming agent: it must NOT acknowledge + * the summary, re-greet, or recap — it picks up from where the prior turn + * left off based on the summary. + * + * Lives in the wrapper (not in the compression system prompt) so the summary + * model does not have to re-generate this text every compaction (saves + * output tokens, prevents wording drift). + */ +const RESUME_TRAILER = + 'Resume the prior task using the summary above. Continue from the last in-flight step; do not acknowledge the summary, do not re-introduce, do not greet the user again.'; + +/** + * Strip the model's drafting scratchpad before the summary becomes the new + * post-compact context. The compression prompt instructs the summary model + * to wrap its chain-of-thought reasoning in an `...` + * block, which is purely for the model's own benefit; keeping it in history + * wastes tokens and degrades signal-to-noise for the resuming agent. + * + * Defensive design: if the strip removes everything (model produced ONLY an + * analysis block with no summary content), fall back to the raw summary so + * the caller sees something rather than an empty string — the inflation + * guard upstream will still NOOP this round, but we don't want to silently + * lose the entire model response. + */ +/** + * Strip `...` chain-of-thought blocks from raw + * summary text. Exposed separately from `postProcessSummary` so the + * PostCompact hook event can receive the same stripped text that + * enters history — without the resume trailer, which is wrapper + * decoration meant for the next agent turn only (Finding 8a). + * + * NOTE on the regex: + * - `[\s\S]*?` (non-greedy) handles newlines inside the block AND + * stops at the first `` — so multiple non-overlapping + * blocks each get stripped via the `/g` flag. + * - It matches the exact tag `` only. If the prompt ever + * evolves to use attributes (e.g. ``) or + * nested `` tags, this pattern will leak content. The + * compression prompt is under our control, so we keep the pattern + * strict rather than over-engineering. + * - The unclosed-tag fallback (`[\s\S]*$`) catches the case + * where the model started an `` block and ran out of + * output tokens before closing it. Without this, the closed-tag + * regex above misses and the entire scratchpad leaks into history + * via the fallback path in `postProcessSummary`. + */ +export function stripAnalysisBlock(rawSummary: string): string { + // First pass: strip well-formed `...` blocks + // (handles multiple via `/g`, newlines via `[\s\S]`). + let result = rawSummary.replace(/[\s\S]*?<\/analysis>\s*/g, ''); + // Second pass: strip any remaining unclosed `` tag (the + // model ran out of output tokens before closing). Uses an + // end-of-string anchor since there's no closing tag to stop at. + result = result.replace(/[\s\S]*$/g, ''); + return result.trim(); +} + +export function postProcessSummary(rawSummary: string): string { + const stripped = stripAnalysisBlock(rawSummary); + // Defensive sentinel only. Callers gate on `isSummaryEmpty`, which now + // checks the STRIPPED summary — so a response that strips to nothing is + // treated as an empty summary upstream (COMPRESSION_FAILED_EMPTY_SUMMARY) + // and never reaches here. The inflation guard does NOT catch this case + // (a tiny `[Summary unavailable]` is smaller than the original, so the + // guard wouldn't fire) — the upstream emptiness check is what prevents it. + const body = stripped.length > 0 ? stripped : '[Summary unavailable]'; + return `${body}\n\n${RESUME_TRAILER}`; +} + +export interface ComposePostCompactOptions { + /** + * Workspace root. When set, file paths from history that resolve + * outside this root are silently skipped (Finding 4). Without this, + * an adversarial model that issued `read_file('/etc/passwd')` — + * even one denied by the permission system — would have its path + * extracted and re-read off disk into the next prompt. + */ + workspaceRoot?: string; + /** + * Cancels in-progress file reads (Finding 5). Propagated to + * `buildFileRestorationBlocks` → `readFileSizeAdaptive` → + * `readFile(path, { signal })`. + */ + signal?: AbortSignal; + /** + * Max recent files to restore. Defaults to + * `POST_COMPACT_MAX_FILES_TO_RESTORE`. Configurable via + * `chatCompression.maxRecentFilesToRetain` (env + * `QWEN_COMPACT_MAX_RECENT_FILES`). + */ + maxFiles?: number; + /** + * Max recent images to restore. Defaults to + * `POST_COMPACT_MAX_IMAGES_TO_RESTORE`. Configurable via + * `chatCompression.maxRecentImagesToRetain` (env + * `QWEN_COMPACT_MAX_RECENT_IMAGES`). + */ + maxImages?: number; +} + +/** + * Trailing `model+functionCall` content from the pre-compact history, + * to be preserved in the post-compact output so a pending + * `functionResponse` (sitting in `sendMessageStream`'s + * `pendingUserMessage` waiting to be pushed) has a matching call + * (Finding 3). Returns `undefined` if the last history entry is not + * a model turn with a functionCall part. + */ +function trailingFunctionCallContent(history: Content[]): Content | undefined { + const last = history[history.length - 1]; + if (!last || last.role !== 'model') return undefined; + if (!last.parts?.some((p) => !!p.functionCall)) return undefined; + return last; +} + +/** + * Resolve and validate a file path against an optional workspace + * root. Returns `true` if the file path lies under `workspaceRoot` + * (or if no root was supplied — caller chose not to enforce). Used + * to skip out-of-workspace paths that a model emitted via a denied + * tool call (Finding 4). + */ +function isInsideWorkspace(filePath: string, workspaceRoot?: string): boolean { + if (!workspaceRoot) return true; + // Resolve symlinks (not just lexical normalization) so a symlink that + // lives INSIDE the workspace but points OUTSIDE — e.g. + // `workspace/.env -> ~/.ssh/id_rsa` — cannot smuggle a sensitive file + // past the boundary and into the post-compact history sent to the + // provider. Mirrors WorkspaceContext.isPathWithinWorkspace's realpath + // handling. + const resolvedFile = safeRealpath(filePath); + const resolvedRoot = safeRealpath(workspaceRoot); + // Append a trailing separator so a sibling path that shares a prefix + // (e.g. workspace=/foo/bar, file=/foo/bar2/x.ts) is correctly + // classified as outside. + const rootWithSep = resolvedRoot.endsWith(pathSep) + ? resolvedRoot + : resolvedRoot + pathSep; + return resolvedFile === resolvedRoot || resolvedFile.startsWith(rootWithSep); +} + +/** + * realpathSync that falls back to lexical resolution when the path does + * not exist (realpathSync throws ENOENT). A non-existent path can't leak + * content anyway — readFileSizeAdaptive returns 'missing' for it — so the + * lexical fallback only affects the boundary classification, not safety. + */ +function safeRealpath(p: string): string { + try { + return realpathSync(p); + } catch { + return resolvePath(p); + } +} + +export async function composePostCompactHistory( + history: Content[], + summary: string, + options: ComposePostCompactOptions = {}, +): Promise { + const { + workspaceRoot, + signal, + maxFiles = POST_COMPACT_MAX_FILES_TO_RESTORE, + maxImages = POST_COMPACT_MAX_IMAGES_TO_RESTORE, + } = options; + + // Workspace-boundary filter on the extracted file paths (Finding 4). + const filePaths = extractRecentFilePaths(history, maxFiles).filter((p) => + isInsideWorkspace(p, workspaceRoot), + ); + const fileBlocks = await buildFileRestorationBlocks(filePaths, signal); + + const images = extractRecentImages(history, maxImages); + const imageBlock = buildImageRestorationBlock(images); + + // Merge every file restoration block AND the image block into a + // single user Content (Finding 2). Pushing them as separate user + // Contents produces consecutive same-role entries, which + // geminiChat.test.ts:6289 enforces against and which Gemini + // providers reject as 400 "consecutive same-role content". + const postAckParts: Part[] = []; + for (const block of fileBlocks) { + for (const part of block.parts ?? []) postAckParts.push(part); + } + if (imageBlock) { + for (const part of imageBlock.parts ?? []) postAckParts.push(part); + } + + // Preserve trailing model+functionCall so a pending functionResponse + // has a matching call (Finding 3). Place it AFTER any merged + // attachments so role alternation holds: + // - with attachments: [user(sum), model(ack), user(attach), model(fc)] + // - without: [user(sum), model(ack + fc)] — fc lands in + // the ack's own model Content to avoid the + // model→model adjacency that would otherwise + // arise from a separate appended entry. + const trailingFc = trailingFunctionCallContent(history); + const ackParts: Part[] = [ + { text: 'Got it. Thanks for the additional context!' }, + ]; + + const out: Content[] = [ + { role: 'user', parts: [{ text: postProcessSummary(summary) }] }, + ]; + + if (postAckParts.length > 0) { + out.push({ role: 'model', parts: ackParts }); + out.push({ role: 'user', parts: postAckParts }); + if (trailingFc) out.push(trailingFc); + } else if (trailingFc) { + // Fold the trailing functionCall into the ack's own Content so we don't + // produce model→model adjacency. Intentionally keep ONLY the + // functionCall parts: the trailing turn's text was already captured in + // the summary, and merging it into the ack would muddy both. (The + // with-attachments branch above keeps trailingFc as its own model turn, + // so text survives there — the asymmetry is deliberate, not a bug.) + const fcParts = (trailingFc.parts ?? []).filter((p) => !!p.functionCall); + out.push({ role: 'model', parts: [...ackParts, ...fcParts] }); + } else { + out.push({ role: 'model', parts: ackParts }); + } + + return out; +} diff --git a/packages/core/src/services/tokenEstimation.ts b/packages/core/src/services/tokenEstimation.ts index cd86642192..2946bc0fab 100644 --- a/packages/core/src/services/tokenEstimation.ts +++ b/packages/core/src/services/tokenEstimation.ts @@ -17,7 +17,7 @@ import { * not byte counts — for CJK / multi-byte text the byte/char ratio differs * from 1, so a "bytes" name would mislead. Programmatically aliased to * compactionInputSlimming.ts's TOKEN_TO_CHAR_RATIO so the auto-compaction - * trigger and the compression splitter can never drift on this constant. + * trigger and the compression size estimator can never drift on this constant. * Matches claude-code's roughTokenCountEstimation default. (review #4168 R3.1) */ export const CHARS_PER_TOKEN = TOKEN_TO_CHAR_RATIO; @@ -27,8 +27,8 @@ export const CHARS_PER_TOKEN = TOKEN_TO_CHAR_RATIO; * * Reuses `estimateContentChars` so that inlineData / functionCall / * functionResponse get the same treatment they receive when computing - * compression split points — keeping the two estimators in sync prevents - * the auto-compaction trigger and the splitter from disagreeing on size. + * compression size estimates — keeping the two estimators in sync prevents + * the auto-compaction trigger and the compressor from disagreeing on size. * * Intended for the pre-send threshold gate only. char/4 is a conservative * lower bound (real tokenizers vary ±30%); using it to TRIGGER compaction