qwen-code/packages/cli/src/ui/components/Tips.test.ts
tanzhenxin 2786afb2b5
fix(core): compact on the window ceiling, not the max of the threshold ladder (#6583)
* fix(core): compact on the window ceiling (min), not the max of the ladder

computeThresholds combined the proportional term (pct*window) and the
absolute term (effectiveWindow - AUTOCOMPACT_BUFFER) with Math.max, which
pushed the auto-compaction trigger toward the top of the window on large
windows — a 1M-token window compacted at ~97%, leaving ~33K headroom.

The absolute term is structurally a ceiling ("compact before the prompt
leaves too little room for the summarization side-query, which needs up
to SUMMARY_RESERVE of output"), so it composes with Math.min, matching
the claude-code reference (services/compact/autoCompact.ts, which uses
Math.min and whose default trigger is the absolute term alone).

  auto = absoluteCeiling > 0 ? min(pct*window, absoluteCeiling) : pct*window
  warn = max(0, auto - WARN_BUFFER)   // WARN_PCT_OFFSET retired
  hard = unchanged

Effect: large windows compact at ~85% (the DEFAULT_PCT ceiling) instead
of ~97%; small/mid windows keep room to run compaction (a 128K window's
summary now provably fits); sub-33K windows are unchanged. A lower
context.autoCompactThreshold now pulls compaction earlier on large
windows, matching the reference's Math.min override semantics.

Updates the threshold unit tests, the settings schema description, and
the user docs to describe the setting as a ceiling on the trigger.

* refactor(core): trim threshold doc comments; name the hard-edge term

Post-review cleanup (no behavior change):
- Collapse the duplicated regime explanation shared between the DEFAULT_PCT
  and computeThresholds doc comments into one canonical block; point the
  constant's doc at computeThresholds.
- Rename rawHard -> hardEdge and note it is the window-edge ceiling, so the
  two roles of the hard tier (window edge vs. auto + HARD_BUFFER) are legible.
- Shorten the context.autoCompactThreshold description in settings.md to the
  concise schema wording (also un-widens the docs table).
2026-07-09 19:25:21 +08:00

171 lines
5.3 KiB
TypeScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, it, expect } from 'vitest';
import {
selectTip,
tipRegistry,
type TipContext,
} from '../../services/tips/index.js';
import { TipHistory } from '../../services/tips/tipHistory.js';
const tempPaths: string[] = [];
function tmpPath(): string {
const p = join(
tmpdir(),
`test-tips-${Date.now()}-${Math.random().toString(36).slice(2)}.json`,
);
tempPaths.push(p);
return p;
}
afterEach(() => {
for (const p of tempPaths) {
rmSync(p, { force: true });
}
tempPaths.length = 0;
});
function createContext(overrides: Partial<TipContext> = {}): TipContext {
return {
lastPromptTokenCount: 0,
contextWindowSize: 1_000_000,
sessionPromptCount: 0,
sessionCount: 1,
platform: 'linux',
// Matches computeThresholds(1_000_000) — kept inline so this test stays
// hermetic to the registry's tier logic rather than re-deriving constants.
thresholds: {
warn: 830_000,
auto: 850_000,
hard: 977_000,
effectiveWindow: 980_000,
},
...overrides,
};
}
function createHistory(): TipHistory {
return new TipHistory({ sessionCount: 1, tips: {} }, tmpPath());
}
describe('selectTip', () => {
it('returns a startup tip for new user', () => {
const ctx = createContext({ sessionCount: 1 });
const history = createHistory();
const tip = selectTip('startup', ctx, tipRegistry, history);
expect(tip).not.toBeNull();
expect(tip!.trigger).toBe('startup');
});
it('returns context-high tip when context usage is high', () => {
const ctx = createContext({
// Between auto (850K) and hard (977K) — context-high band.
lastPromptTokenCount: 970_000,
contextWindowSize: 1_000_000,
sessionPromptCount: 10,
});
const history = createHistory();
const tip = selectTip('post-response', ctx, tipRegistry, history);
expect(tip).not.toBeNull();
expect(tip!.id).toBe('context-high');
});
it('returns context-critical tip when context usage is critical', () => {
const ctx = createContext({
// At/above hard (977K) — context-critical band.
lastPromptTokenCount: 980_000,
contextWindowSize: 1_000_000,
sessionPromptCount: 10,
});
const history = createHistory();
const tip = selectTip('post-response', ctx, tipRegistry, history);
expect(tip).not.toBeNull();
expect(tip!.id).toBe('context-critical');
});
it('returns compress-intro tip when context is moderate and session is long', () => {
const ctx = createContext({
// Between warn (830K) and auto (850K) — compress-intro band.
lastPromptTokenCount: 840_000,
contextWindowSize: 1_000_000,
sessionPromptCount: 10,
});
const history = createHistory();
const tip = selectTip('post-response', ctx, tipRegistry, history);
expect(tip).not.toBeNull();
expect(tip!.id).toBe('compress-intro');
});
it('returns null for post-response when context usage is low', () => {
const ctx = createContext({
lastPromptTokenCount: 100_000,
contextWindowSize: 1_000_000,
sessionPromptCount: 2,
});
const history = createHistory();
const tip = selectTip('post-response', ctx, tipRegistry, history);
expect(tip).toBeNull();
});
it('respects cooldown — does not re-show same tip within cooldown period', () => {
const ctx = createContext({
lastPromptTokenCount: 970_000,
contextWindowSize: 1_000_000,
sessionPromptCount: 10,
});
const history = createHistory();
// First selection should return context-high
const tip1 = selectTip('post-response', ctx, tipRegistry, history);
expect(tip1!.id).toBe('context-high');
// Record it as shown
history.recordShown(tip1!.id, 10);
// Same prompt count — should be cooled down, skip context-high
const tip2 = selectTip('post-response', ctx, tipRegistry, history);
// Should either be null or a different tip
expect(tip2?.id).not.toBe('context-high');
});
it('selects a new-user tip for brand new users', () => {
const ctx = createContext({ sessionCount: 1 });
const history = createHistory();
const tip = selectTip('startup', ctx, tipRegistry, history);
// New user tips have priority 70, so one of them should be selected
expect(tip).not.toBeNull();
expect(tip!.priority).toBe(70);
});
it('rotates startup tips across sessions via LRU', () => {
const ctx = createContext({ sessionCount: 1 });
const history = createHistory();
// Pick first tip
const tip1 = selectTip('startup', ctx, tipRegistry, history);
expect(tip1).not.toBeNull();
history.recordShown(tip1!.id, 0);
// Pick second tip — should be different due to LRU
const tip2 = selectTip('startup', ctx, tipRegistry, history);
expect(tip2).not.toBeNull();
expect(tip2!.id).not.toBe(tip1!.id);
});
it('returns a priority-70 tip for experienced users with insight available', () => {
const ctx = createContext({ sessionCount: 25 });
const history = createHistory();
const tip = selectTip('startup', ctx, tipRegistry, history);
// insight-command has priority 70, same as other new-user tips
expect(tip).not.toBeNull();
expect(tip!.priority).toBe(70);
});
});