Files
vscode/extensions/copilot/src/platform/inlineEdits/test/common/xtabPromptOptions.spec.ts
T
Ulugbek Abdullaev 3073c2bb48 NES: experiment-driven global token budget for the inline-edits prompt (#323948)
* nes: fold currentFile budgeting into the global budget cascade

Under the opt-in global budget experiment, the current file now draws its
clip budget from the shared pool (its `shares.currentFile` slice of
`totalTokens`) and donates whatever it does not use to the cascade as the
initial surplus. When the global budget is disabled (prod default) behavior
is byte-identical, and the new defaults (totalTokens 8000, shares in eighths)
reproduce today's per-part `maxTokens` caps exactly.

- xtabPromptOptions: add `GlobalBudgetSharePart`, `currentFileBudget()` and
  `validate()`; grow `shares` to include `currentFile`; bump
  `DEFAULT_TOTAL_TOKENS` to 8000 and rebalance `DEFAULT_SHARES`.
- promptCrafting: seed the cascade's initial surplus from the current file's
  leftover budget; delegate validation to the namespace helper.
- xtabProvider: size and clip the current file from the pool and compute the
  surplus, gated on `globalBudget` being defined.
- Tests: volume-neutral invariant, `currentFileBudget`/`validate` units, and
  provider-level regression + new-behavior coverage.
- Docs: rewrite globalBudgetCascade.md for the seeded-surplus design.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* nes: collapse global-budget settings into a single exp-driven JSON string

Replace the two experiment-driven global-budget settings
(globalBudget.enabled boolean + globalBudget.totalTokens number) with a
single JSON-encoded string setting, modelled after modelConfigurationString,
that defines totalTokens, order, and shares together.

- configurationService: remove InlineEditsXtabGlobalBudgetEnabled and
  InlineEditsXtabGlobalBudgetTotalTokens; add InlineEditsXtabGlobalBudget
  (string | undefined, ExperimentBased, default undefined).
- xtabPromptOptions: add GlobalBudgetOptions.VALIDATOR (all top-level fields
  optional; shares must be complete when present) and a pure
  GlobalBudgetOptions.fromConfigString(): Result<GlobalBudgetOptions, string>
  that JSON-parses, structurally validates, merges over the defaults, then
  runs the semantic validate(). Never throws.
- xtabProvider: inject ITelemetryService; resolve the budget via
  getGlobalBudget(), returning undefined (disabled, identical to prod) when
  the string is unset/empty/invalid and emitting incorrectNesGlobalBudgetConfig
  telemetry on parse/validation failure.
- Tests: migrate the provider global-budget tests to the JSON string and add
  fromConfigString unit tests.
- Docs: rewrite the globalBudgetCascade.md Wiring section and migration note.

No regression: an unset string keeps the byte-identical legacy path. '{}'
enables the budget with the volume-neutral defaults; {"totalTokens":N} only
overrides the pool size.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* nes: clip the current file last under a global budget to reuse cascade leftover

Under a global budget the current file is now clipped LAST, sized to its base
share plus whatever the budget cascade left unused (`currentFileBudget +
finalSurplus`), so it trims less and reuses leftover budget. The cascade runs
first (seeded with 0, the current file never donates) and is threaded into
`getUserPrompt` via `precomputedCascade` so it renders exactly once.

This replaces the earlier two-mode design: the opt-in `reuseLeftoverForCurrentFile`
flag and the seeded-surplus "donate-forward" path are dropped, along with all
`currentFileBudgetSurplus`/`initialSurplus` threading. Clip-last is now the single
behavior under a global budget. The validator hardening (rejecting non-finite or
negative `totalTokens`/shares) is kept.

No production regression: when `globalBudget` is undefined the current file is
still clipped to its own `currentFile.maxTokens` with the legacy await ordering,
byte-identical to before. The global budget remains experiment-gated and off in
prod.

Tests and globalBudgetCascade.md updated for the clip-last-only behavior.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* nes: address review — restore nLinesOfCurrentFileInPrompt telemetry timing

- xtabProvider: record setNLinesOfCurrentFileInPrompt inside both budget
  branches. In the legacy/prod branch the clip happens before the
  context-gathering awaits, so the telemetry call is moved back there to
  match prod timing exactly (it is still emitted when a request is
  cancelled mid-gathering). The clip-last branch records it after the
  cascade, as the clip is inherently last there.
- xtabPromptOptions: fix two stale JSDoc blocks that still described the
  removed donate-forward behavior; describe clip-last instead.
- globalBudgetCascade.md: correct the conservation bound to T·(Σ shares)
  and document the share-sum tolerance (over-allocation ≤ ~1e-3·T).
- promptCrafting.spec: add a cascade test asserting finalSurplus shrinks
  as the cascade consumes budget (current file reuses less leftover).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* nes: fix stale DEFAULT_ORDER doc comment to say clip-last

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* nes: rename clipCurrentFileToBudget param to overriddenMaxTokens

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* nes: extract current-file clip + context gathering into a method

Pulls the ~55-line global-budget/legacy if-else out of doGetNextEditWithSelection
into a private gatherContextAndClipCurrentFile method returning
Result<{ clippedTaggedCurrentDoc, areaAroundCodeToEdit, precomputedCascade,
langCtx, neighborSnippets }, NoNextEditReason>. Behavior, clip-first/clip-last
ordering, and nLinesOfCurrentFileInPrompt telemetry timing are unchanged.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* nes: recalibrate global-budget defaults to merged currentFile cap (1500)

main (#322382) lowered currentFile.maxTokens 2000->1500, which broke the
global-budget 'volume-neutral defaults' invariant (floor(total*share) must
equal each part's legacy cap). Recalibrate DEFAULT_SHARES to cap/7500 and set
DEFAULT_TOTAL_TOKENS to 7500 (the sum of the merged per-part caps: 1500 + 2000
+ 2000 + 1000 + 1000), so shares still sum to exactly 1 and every part's base
allocation reproduces its cap. finalSurplus stays 6000 (7500 - 1500).

Update dependent specs (literal 8000 -> DEFAULT_TOTAL_TOKENS, stale 2000 cap
comments -> 1500) and the design doc (defaults table, worked examples, effective
caps, ordering caveat, migration note) for T=7500 and currentFile base 1500.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-02 16:48:34 +00:00

257 lines
11 KiB
TypeScript

/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { describe, expect, it } from 'vitest';
import { ImportChanges } from '../../common/dataTypes/importFilteringOptions';
import { applyStrategyConfig, DEFAULT_OPTIONS, GlobalBudgetOptions, IncludeLineNumbersOption, MODEL_CONFIGURATION_VALIDATOR, ModelConfiguration, PromptingStrategy } from '../../common/dataTypes/xtabPromptOptions';
function baseConfig(overrides: Partial<ModelConfiguration> = {}): ModelConfiguration {
return {
modelName: 'test-model',
promptingStrategy: undefined,
includeTagsInCurrentFile: true,
lintOptions: undefined,
...overrides,
};
}
describe('applyStrategyConfig', () => {
it('returns config unchanged when strategy has no entry', () => {
const config = baseConfig({ promptingStrategy: PromptingStrategy.Xtab275 });
expect(applyStrategyConfig(config)).toBe(config);
});
it('returns config unchanged when strategy is undefined', () => {
const config = baseConfig();
expect(applyStrategyConfig(config)).toBe(config);
});
it('forces includeTagsInCurrentFile=true for CopilotNesXtab', () => {
const result = applyStrategyConfig(baseConfig({
promptingStrategy: PromptingStrategy.CopilotNesXtab,
includeTagsInCurrentFile: false,
}));
expect(result.includeTagsInCurrentFile).toBe(true);
});
it('forces baked-in fields for PatchBased02WithRecentLineNumbers', () => {
const result = applyStrategyConfig(baseConfig({
promptingStrategy: PromptingStrategy.PatchBased02WithRecentLineNumbers,
includeTagsInCurrentFile: true,
includePostScript: false,
currentFile: { includeLineNumbers: IncludeLineNumbersOption.None, maxTokens: 42 },
recentlyViewedDocuments: { includeLineNumbers: IncludeLineNumbersOption.None, maxTokens: 99 },
supportsNextCursorLinePrediction: true,
}));
expect(result).toMatchObject({
includeTagsInCurrentFile: false,
includePostScript: true,
currentFile: { includeLineNumbers: IncludeLineNumbersOption.WithoutSpace, maxTokens: 42 },
recentlyViewedDocuments: { includeLineNumbers: IncludeLineNumbersOption.WithoutSpace, maxTokens: 99 },
supportsNextCursorLinePrediction: false,
});
});
it('forces recentlyViewedDocuments.includeLineNumbers=None for PatchBased02WithoutRecentLineNumbers', () => {
const result = applyStrategyConfig(baseConfig({
promptingStrategy: PromptingStrategy.PatchBased02WithoutRecentLineNumbers,
recentlyViewedDocuments: { includeLineNumbers: IncludeLineNumbersOption.WithSpaceAfter },
}));
expect(result.recentlyViewedDocuments?.includeLineNumbers).toBe(IncludeLineNumbersOption.None);
expect(result.currentFile?.includeLineNumbers).toBe(IncludeLineNumbersOption.WithoutSpace);
});
it('preserves undefined for option bags neither side specifies', () => {
const result = applyStrategyConfig(baseConfig({
promptingStrategy: PromptingStrategy.CopilotNesXtab,
}));
// CopilotNesXtab only sets includeTagsInCurrentFile; nested option bags should stay undefined.
expect(result.currentFile).toBeUndefined();
expect(result.recentlyViewedDocuments).toBeUndefined();
expect(result.lintOptions).toBeUndefined();
});
it('preserves allowImportChanges through strategy application', () => {
const result = applyStrategyConfig(baseConfig({
promptingStrategy: PromptingStrategy.CopilotNesXtab,
allowImportChanges: ImportChanges.All,
}));
expect(result.allowImportChanges).toBe(ImportChanges.All);
});
});
describe('MODEL_CONFIGURATION_VALIDATOR', () => {
it('accepts a config with allowImportChanges', () => {
const result = MODEL_CONFIGURATION_VALIDATOR.validate(baseConfig({ allowImportChanges: ImportChanges.All }));
expect(result.error).toBeUndefined();
expect(result.content?.allowImportChanges).toBe(ImportChanges.All);
});
it('accepts a config without allowImportChanges', () => {
const result = MODEL_CONFIGURATION_VALIDATOR.validate(baseConfig());
expect(result.error).toBeUndefined();
expect(result.content?.allowImportChanges).toBeUndefined();
});
it('rejects an invalid allowImportChanges value', () => {
const result = MODEL_CONFIGURATION_VALIDATOR.validate(baseConfig({ allowImportChanges: 'sometimes' as ImportChanges }));
expect(result.error).toBeDefined();
});
});
describe('GlobalBudgetOptions', () => {
function gb(overrides: Partial<GlobalBudgetOptions> = {}): GlobalBudgetOptions {
return {
totalTokens: GlobalBudgetOptions.DEFAULT_TOTAL_TOKENS,
order: GlobalBudgetOptions.DEFAULT_ORDER,
shares: GlobalBudgetOptions.DEFAULT_SHARES,
...overrides,
};
}
describe('volume-neutral defaults', () => {
// Guards the core no-regression promise: enabling the global budget with the
// default total + shares must reproduce today's per-part `maxTokens` caps
// exactly. If anyone changes DEFAULT_SHARES or DEFAULT_TOTAL_TOKENS in a way
// that shifts a part's budget, this fails loudly instead of silently
// shrinking/growing prompts in the experiment arm.
it('reproduce the legacy per-part caps', () => {
const total = GlobalBudgetOptions.DEFAULT_TOTAL_TOKENS;
const shares = GlobalBudgetOptions.DEFAULT_SHARES;
const computed = {
currentFile: Math.floor(total * shares.currentFile),
recentlyViewedDocuments: Math.floor(total * shares.recentlyViewedDocuments),
languageContext: Math.floor(total * shares.languageContext),
neighborFiles: Math.floor(total * shares.neighborFiles),
diffHistory: Math.floor(total * shares.diffHistory),
};
expect(computed).toEqual({
currentFile: DEFAULT_OPTIONS.currentFile.maxTokens,
recentlyViewedDocuments: DEFAULT_OPTIONS.recentlyViewedDocuments.maxTokens,
languageContext: DEFAULT_OPTIONS.languageContext.maxTokens,
neighborFiles: DEFAULT_OPTIONS.neighborFiles.maxTokens,
diffHistory: DEFAULT_OPTIONS.diffHistory.maxTokens,
});
});
it('shares sum to exactly 1', () => {
const shares = GlobalBudgetOptions.DEFAULT_SHARES;
const sum = shares.currentFile + shares.recentlyViewedDocuments + shares.languageContext + shares.neighborFiles + shares.diffHistory;
expect(sum).toBe(1);
});
});
describe('currentFileBudget', () => {
it('floors totalTokens * shares.currentFile', () => {
expect(GlobalBudgetOptions.currentFileBudget(gb({ totalTokens: 8000, shares: { ...GlobalBudgetOptions.DEFAULT_SHARES, currentFile: 2 / 8 } }))).toBe(2000);
expect(GlobalBudgetOptions.currentFileBudget(gb({ totalTokens: 999, shares: { ...GlobalBudgetOptions.DEFAULT_SHARES, currentFile: 1 / 3 } }))).toBe(333);
});
it('clamps at 0 for a zero share', () => {
expect(GlobalBudgetOptions.currentFileBudget(gb({ totalTokens: 8000, shares: { ...GlobalBudgetOptions.DEFAULT_SHARES, currentFile: 0 } }))).toBe(0);
});
});
describe('validate', () => {
it('accepts the defaults', () => {
expect(() => GlobalBudgetOptions.validate(gb())).not.toThrow();
});
it('throws on a duplicate part in order', () => {
expect(() => GlobalBudgetOptions.validate(gb({
order: ['languageContext', 'languageContext', 'recentlyViewedDocuments', 'neighborFiles', 'diffHistory'],
}))).toThrow(/duplicate part 'languageContext'/);
});
it('throws when shares omit currentFile', () => {
expect(() => GlobalBudgetOptions.validate(gb({
shares: { languageContext: 0.25, recentlyViewedDocuments: 0.25, neighborFiles: 0.25, diffHistory: 0.25 } as GlobalBudgetOptions['shares'],
}))).toThrow(/missing entry for 'currentFile'/);
});
it('throws when shares do not sum to ~1 across order plus currentFile', () => {
expect(() => GlobalBudgetOptions.validate(gb({
shares: { ...GlobalBudgetOptions.DEFAULT_SHARES, currentFile: 0.9 },
}))).toThrow(/shares across order must sum to ~1/);
});
it('throws when neighborFiles is ordered before recentlyViewedDocuments', () => {
expect(() => GlobalBudgetOptions.validate(gb({
order: ['languageContext', 'neighborFiles', 'recentlyViewedDocuments', 'diffHistory'],
}))).toThrow(/must place 'recentlyViewedDocuments' before 'neighborFiles'/);
});
it('throws on a negative totalTokens', () => {
expect(() => GlobalBudgetOptions.validate(gb({ totalTokens: -1 }))).toThrow(/totalTokens must be a finite, non-negative number/);
});
it('throws on a negative share (which would break budget conservation)', () => {
// Sums to 1 so the legacy sum check passes, but a negative share clamps to
// a 0 allocation yet still counts toward the sum, letting other parts
// over-allocate past the pool. Must be rejected.
expect(() => GlobalBudgetOptions.validate(gb({
shares: { currentFile: 0.25, languageContext: -0.25, recentlyViewedDocuments: 1.0, neighborFiles: 0, diffHistory: 0 },
}))).toThrow(/must be a finite, non-negative number/);
});
it('throws on a non-finite share', () => {
expect(() => GlobalBudgetOptions.validate(gb({
shares: { ...GlobalBudgetOptions.DEFAULT_SHARES, currentFile: Number.NaN },
}))).toThrow(/must be a finite, non-negative number/);
});
});
describe('fromConfigString', () => {
it('fills every omitted field with the defaults', () => {
const result = GlobalBudgetOptions.fromConfigString('{}');
expect(result.isOk() && result.val).toEqual({
totalTokens: GlobalBudgetOptions.DEFAULT_TOTAL_TOKENS,
order: GlobalBudgetOptions.DEFAULT_ORDER,
shares: GlobalBudgetOptions.DEFAULT_SHARES,
});
});
it('overrides only the fields present in the JSON', () => {
const result = GlobalBudgetOptions.fromConfigString(JSON.stringify({ totalTokens: 6000 }));
expect(result.isOk() && result.val).toEqual({
totalTokens: 6000,
order: GlobalBudgetOptions.DEFAULT_ORDER,
shares: GlobalBudgetOptions.DEFAULT_SHARES,
});
});
it('parses a fully-specified config and ignores unknown keys', () => {
const order: GlobalBudgetOptions['order'] = ['languageContext', 'recentlyViewedDocuments', 'neighborFiles', 'diffHistory'];
const shares: GlobalBudgetOptions['shares'] = { currentFile: 0.4, languageContext: 0.2, recentlyViewedDocuments: 0.2, neighborFiles: 0.1, diffHistory: 0.1 };
const result = GlobalBudgetOptions.fromConfigString(JSON.stringify({ totalTokens: 12000, order, shares, unknown: 'ignored' }));
expect(result.isOk() && result.val).toEqual({ totalTokens: 12000, order, shares });
});
it('errors on malformed JSON', () => {
expect(GlobalBudgetOptions.fromConfigString('{ not json').isError()).toBe(true);
});
it('errors when a field has the wrong type', () => {
expect(GlobalBudgetOptions.fromConfigString(JSON.stringify({ totalTokens: '6000' })).isError()).toBe(true);
});
it('errors on an unknown part in order', () => {
expect(GlobalBudgetOptions.fromConfigString(JSON.stringify({ order: ['languageContext', 'bogus'] })).isError()).toBe(true);
});
it('errors when shares are partial (every part is required)', () => {
expect(GlobalBudgetOptions.fromConfigString(JSON.stringify({ shares: { currentFile: 1 } })).isError()).toBe(true);
});
it('errors when the merged config is semantically invalid', () => {
const shares = { currentFile: 0.9, languageContext: 0.2, recentlyViewedDocuments: 0.2, neighborFiles: 0.1, diffHistory: 0.1 };
expect(GlobalBudgetOptions.fromConfigString(JSON.stringify({ shares })).isError()).toBe(true);
});
});
});