fix: align v2 compaction auth guards

This commit is contained in:
_Kerman 2026-07-08 17:58:52 +08:00
parent e3fb6d249e
commit ca237a0264
4 changed files with 76 additions and 18 deletions

View file

@ -115,7 +115,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
@ITelemetryService private readonly telemetry: ITelemetryService,
@IAgentWireService private readonly wire: IWireService,
@IEventBus private readonly eventBus: IEventBus,
@IAgentTurnService turnService: IAgentTurnService,
@IAgentTurnService private readonly turn: IAgentTurnService,
@IAgentLoopService loopService: IAgentLoopService,
) {
super();
@ -158,6 +158,12 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
if (this.compactionCountInTurn > this.strategy.maxCompactionPerTurn) return false;
const history = this.context.get();
if (data.source === 'manual' && this.turn.getActiveTurn() !== undefined) {
throw new KimiError(
ErrorCodes.COMPACTION_UNABLE,
'Cannot compact while a turn is active. Wait for it to finish, then retry.',
);
}
const tokenCount = estimateTokensForMessages(history);
const compactedCount = this.strategy.computeCompactCount(history, data.source);
if (compactedCount === 0) {

View file

@ -285,7 +285,9 @@ export class ModelResolverService extends Disposable implements IModelResolver {
async getAuth(options): Promise<ProviderRequestAuth | undefined> {
const tokenProvider = oauthService.resolveTokenProvider(providerKey, oauthRef);
if (tokenProvider === undefined) throw loginRequired();
const apiKey = await tokenProvider.getAccessToken({ force: options?.force ?? false });
const apiKey = await tokenProvider.getAccessToken(
options?.force === true ? { force: true } : undefined,
);
if (apiKey.trim().length === 0) throw loginRequired();
return { apiKey };
},

View file

@ -19,7 +19,7 @@ import { microCompactionFlag } from '#/agent/microCompaction/flag';
import { estimateTokensForMessages } from '#/_base/utils/tokens';
import { recordingTelemetry, type TelemetryRecord } from '../telemetry/stubs';
import type { TestAgentContext, TestAgentOptions, TestAgentServiceOverride } from '../harness';
import { appServices, testAgent } from '../harness';
import { appServices, createCommandRunner, execEnvServices, testAgent } from '../harness';
import {
IAgentFullCompactionService,
IAgentMicroCompactionService,
@ -27,6 +27,7 @@ import {
IAgentProfileService,
ISessionTodoService,
} from '#/index';
import { IAgentTurnService } from '#/agent/turn/turn';
type GenerateFn = NonNullable<TestAgentOptions['generate']>;
@ -273,7 +274,7 @@ describe('FullCompaction', () => {
compacted_count: 6,
retry_count: 0,
thinking_level: 'off',
input_other: 520,
input_other: 1181,
output: 8,
input_cache_read: 0,
input_cache_creation: 0,
@ -282,6 +283,36 @@ describe('FullCompaction', () => {
await ctx.expectResumeMatches();
});
it('rejects a manual compaction while a turn is active', async () => {
const ctx = testAgent(execEnvServices({ processRunner: createCommandRunner('should-not-run') }));
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
tools: ['Bash'],
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.mockNextResponse({ type: 'text', text: 'I will wait for approval.' }, bashCall());
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Start the active turn' }] });
const approval = await ctx.takeApprovalRequest();
expect(ctx.get(IAgentTurnService).getActiveTurn()).toBeDefined();
await expect(ctx.rpc.beginCompaction({})).rejects.toMatchObject({
code: 'compaction.unable',
message: 'Cannot compact while a turn is active. Wait for it to finish, then retry.',
});
const events = ctx.newEvents();
expect(eventIndex(events, 'full_compaction.begin')).toBe(-1);
expect(eventIndex(events, 'compaction.started')).toBe(-1);
expect(ctx.get(IAgentFullCompactionService).compacting).toBeNull();
expect(ctx.llmCalls).toHaveLength(1);
ctx.mockNextResponse({ type: 'text', text: 'Turn done.' });
approval.respond({ decision: 'rejected', selectedLabel: 'reject' });
await ctx.untilTurnEnd();
expect(ctx.get(IAgentTurnService).getActiveTurn()).toBeUndefined();
});
it('projects the compacted prefix before sending the summary request', async () => {
const ctx = testAgent();
ctx.configure({
@ -434,7 +465,12 @@ describe('FullCompaction', () => {
expect(authKeys).toEqual(['fresh-token', 'forced-refresh-token', 'fresh-token']);
expect(tokenCalls).toEqual([undefined, true, undefined]);
expect(ctx.compactHistory()).toEqual([
{ role: 'assistant', text: 'Recovered compacted summary.' },
{ role: 'user', text: 'old user one' },
{ role: 'user', text: 'recent user two' },
{
role: 'user',
text: expect.stringContaining('Recovered compacted summary.'),
},
]);
await ctx.expectResumeMatches();
});
@ -1595,7 +1631,12 @@ describe('FullCompaction', () => {
expect(ctx.llmCalls).toHaveLength(1);
expect(ctx.compactHistory()).toEqual([
{ role: 'assistant', text: 'Compacted after no-op cancel.' },
{ role: 'user', text: 'old user one' },
{ role: 'user', text: 'recent user two' },
{
role: 'user',
text: expect.stringContaining('Compacted after no-op cancel.'),
},
]);
await ctx.expectResumeMatches();
});
@ -2490,6 +2531,15 @@ function textMessage(role: 'user' | 'assistant', text: string): Message {
};
}
function bashCall(): ToolCall {
return {
type: 'function',
id: 'call_bash',
name: 'Bash',
arguments: JSON.stringify({ command: 'printf should-not-run', timeout: 60 }),
};
}
function messageText(message: Message | undefined): string {
return message?.content.map((part) => (part.type === 'text' ? part.text : '')).join('') ?? '';
}

View file

@ -299,12 +299,12 @@ describe('ModelResolverService', () => {
it('force-refreshes OAuth credentials and replays a request after 401', async () => {
configureOAuthModel();
const tokenCalls: boolean[] = [];
const tokenCalls: Array<boolean | undefined> = [];
const authKeys: string[] = [];
resolveTokenProvider.mockReturnValue({
getAccessToken: async (options: { readonly force: boolean }) => {
tokenCalls.push(options.force);
return options.force ? 'forced-refresh-token' : 'fresh-token';
getAccessToken: async (options?: { readonly force?: boolean }) => {
tokenCalls.push(options?.force);
return options?.force === true ? 'forced-refresh-token' : 'fresh-token';
},
});
generateImpl = async (_system, _tools, _history, options) => {
@ -333,7 +333,7 @@ describe('ModelResolverService', () => {
}
expect(authKeys).toEqual(['fresh-token', 'forced-refresh-token']);
expect(tokenCalls).toEqual([false, true]);
expect(tokenCalls).toEqual([undefined, true]);
expect(events).toContainEqual({ type: 'part', part: { type: 'text', text: 'recovered' } });
});
@ -341,8 +341,8 @@ describe('ModelResolverService', () => {
configureOAuthModel();
const authKeys: string[] = [];
resolveTokenProvider.mockReturnValue({
getAccessToken: async (options: { readonly force: boolean }) =>
options.force ? 'forced-refresh-token' : 'fresh-token',
getAccessToken: async (options?: { readonly force?: boolean }) =>
options?.force === true ? 'forced-refresh-token' : 'fresh-token',
});
generateImpl = async (_system, _tools, _history, options) => {
authKeys.push(options?.auth?.apiKey ?? '<missing>');
@ -393,12 +393,12 @@ describe('ModelResolverService', () => {
it('force-refreshes OAuth credentials and replays video upload after 401', async () => {
configureOAuthModel();
const tokenCalls: boolean[] = [];
const tokenCalls: Array<boolean | undefined> = [];
const authKeys: string[] = [];
resolveTokenProvider.mockReturnValue({
getAccessToken: async (options: { readonly force: boolean }) => {
tokenCalls.push(options.force);
return options.force ? 'forced-refresh-token' : 'fresh-token';
getAccessToken: async (options?: { readonly force?: boolean }) => {
tokenCalls.push(options?.force);
return options?.force === true ? 'forced-refresh-token' : 'fresh-token';
},
});
uploadVideoImpl = async (_input, options) => {
@ -416,7 +416,7 @@ describe('ModelResolverService', () => {
videoUrl: { url: 'https://example.test/video' },
});
expect(authKeys).toEqual(['fresh-token', 'forced-refresh-token']);
expect(tokenCalls).toEqual([false, true]);
expect(tokenCalls).toEqual([undefined, true]);
});
});