From ca237a02641eca762fa009d97a6636caac5b6ad6 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 8 Jul 2026 17:58:52 +0800 Subject: [PATCH] fix: align v2 compaction auth guards --- .../fullCompaction/fullCompactionService.ts | 8 ++- .../src/app/model/modelResolverService.ts | 4 +- .../test/fullCompaction/full.test.ts | 58 +++++++++++++++++-- .../test/model/modelResolver.test.ts | 24 ++++---- 4 files changed, 76 insertions(+), 18 deletions(-) diff --git a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts index b56204bb9..c2895752b 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts +++ b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts @@ -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) { diff --git a/packages/agent-core-v2/src/app/model/modelResolverService.ts b/packages/agent-core-v2/src/app/model/modelResolverService.ts index f35d81099..8081fc08b 100644 --- a/packages/agent-core-v2/src/app/model/modelResolverService.ts +++ b/packages/agent-core-v2/src/app/model/modelResolverService.ts @@ -285,7 +285,9 @@ export class ModelResolverService extends Disposable implements IModelResolver { async getAuth(options): Promise { 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 }; }, diff --git a/packages/agent-core-v2/test/fullCompaction/full.test.ts b/packages/agent-core-v2/test/fullCompaction/full.test.ts index 0af940c65..c657cf455 100644 --- a/packages/agent-core-v2/test/fullCompaction/full.test.ts +++ b/packages/agent-core-v2/test/fullCompaction/full.test.ts @@ -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; @@ -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('') ?? ''; } diff --git a/packages/agent-core-v2/test/model/modelResolver.test.ts b/packages/agent-core-v2/test/model/modelResolver.test.ts index beefba2dd..880acfd1e 100644 --- a/packages/agent-core-v2/test/model/modelResolver.test.ts +++ b/packages/agent-core-v2/test/model/modelResolver.test.ts @@ -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 = []; 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 ?? ''); @@ -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 = []; 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]); }); });