mirror of
https://github.com/microsoft/vscode.git
synced 2026-07-09 16:24:45 +01:00
3073c2bb48
* 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>
257 lines
11 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|