Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 54 additions & 2 deletions integration-tests/interactive/context-compress-interactive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ describe('Interactive Mode', () => {
const { ptyProcess } = rig.runInteractive();

let fullOutput = '';
ptyProcess.onData((data) => (fullOutput += data));
ptyProcess.onData((data: string) => (fullOutput += data));

// Wait for the app to be ready
const isReady = await rig.waitForText('Type your message', 15000);
Expand Down Expand Up @@ -80,7 +80,7 @@ describe('Interactive Mode', () => {
const { ptyProcess } = rig.runInteractive();

let fullOutput = '';
ptyProcess.onData((data) => (fullOutput += data));
ptyProcess.onData((data: string) => (fullOutput += data));

// Wait for the app to be ready
const isReady = await rig.waitForText('Type your message', 25000);
Expand All @@ -105,4 +105,56 @@ describe('Interactive Mode', () => {

expect(compressionFailed).toBe(true);
});

it.skipIf(process.platform === 'win32')(
'should forward /compress instructions through to the side-query',
async () => {
await rig.setup('interactive-compress-instructions-test', {
settings: {
security: {
auth: {
selectedType: 'openai',
},
},
},
});

const { ptyProcess } = rig.runInteractive();

let fullOutput = '';
ptyProcess.onData((data: string) => (fullOutput += data));

const isReady = await rig.waitForText('Type your message', 15000);
expect(
isReady,
'CLI did not start up in interactive mode correctly',
).toBe(true);

// Seed history so /compress has material to summarize.
const seedPrompt =
'Dont do anything except returning a 1000 token long paragragh with the <name of the scientist who discovered theory of relativity> at the end to indicate end of response. This is a moderately long sentence.';

await type(ptyProcess, seedPrompt);
await type(ptyProcess, '\r');

await rig.waitForText('einstein', 25000);

// Fire /compress with a trailing instruction. We are not asserting on
// summary CONTENT (model behaviour) — only that the wiring runs
// end-to-end and the compression telemetry event lands. Earlier unit
// tests cover the prompt-composition path; this is the smoke test that
// the args plumbing reaches the side-query.
await type(ptyProcess, '/compress focus on the scientist mentioned');
await new Promise((resolve) => setTimeout(resolve, 100));
await type(ptyProcess, '\r');

const foundEvent = await rig.waitForTelemetryEvent(
'chat_compression',
90000,
);
expect(foundEvent, 'chat_compression telemetry event was not found').toBe(
true,
);
},
);
});
132 changes: 132 additions & 0 deletions packages/cli/src/ui/commands/compressCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ describe('compressCommand', () => {
expect(mockTryCompressChat).toHaveBeenCalledWith(
expect.stringMatching(/^compress-\d+$/),
true,
undefined,
undefined,
);

expect(context.ui.addItem).toHaveBeenCalledWith(
Expand Down Expand Up @@ -131,4 +133,134 @@ describe('compressCommand', () => {
await compressCommand.action!(context, '');
expect(context.ui.setPendingItem).toHaveBeenCalledWith(null);
});

Comment thread
LaZzyMan marked this conversation as resolved.
Comment thread
LaZzyMan marked this conversation as resolved.
describe('custom instructions argument', () => {
beforeEach(() => {
mockTryCompressChat.mockResolvedValue({
originalTokenCount: 200,
compressionStatus: CompressionStatus.COMPRESSED,
newTokenCount: 100,
} satisfies ChatCompressionInfo);
});

it('forwards trimmed instructions as the 4th argument', async () => {
const ctx = createMockCommandContext({
services: {
config: {
getGeminiClient: () =>
({
tryCompressChat: mockTryCompressChat,
}) as unknown as GeminiClient,
},
},
invocation: {
raw: '/compress focus on auth bug ',
name: 'compress',
args: ' focus on auth bug ',
},
});
await compressCommand.action!(ctx, '');
expect(mockTryCompressChat).toHaveBeenCalledWith(
expect.stringMatching(/^compress-\d+$/),
true,
undefined,
'focus on auth bug',
);
});

it('passes undefined when args is empty or whitespace only', async () => {
const ctx = createMockCommandContext({
services: {
config: {
getGeminiClient: () =>
({
tryCompressChat: mockTryCompressChat,
}) as unknown as GeminiClient,
},
},
invocation: { raw: '/compress ', name: 'compress', args: ' ' },
});
await compressCommand.action!(ctx, '');
expect(mockTryCompressChat).toHaveBeenCalledWith(
expect.stringMatching(/^compress-\d+$/),
true,
undefined,
undefined,
);
});

it('caps overlong instructions at 2000 chars', async () => {
const long = 'x'.repeat(3000);
const ctx = createMockCommandContext({
services: {
config: {
getGeminiClient: () =>
({
tryCompressChat: mockTryCompressChat,
}) as unknown as GeminiClient,
},
},
invocation: {
raw: `/compress ${long}`,
name: 'compress',
args: long,
},
});
await compressCommand.action!(ctx, '');
const call = mockTryCompressChat.mock.calls[0];
expect(call[3]).toBeDefined();
expect((call[3] as string).length).toBe(2000);
});

it('surfaces an INFO notice to the user when instructions are truncated', async () => {
const long = 'x'.repeat(3000);
const ctx = createMockCommandContext({
services: {
config: {
getGeminiClient: () =>
({
tryCompressChat: mockTryCompressChat,
}) as unknown as GeminiClient,
},
},
invocation: { raw: `/compress ${long}`, name: 'compress', args: long },
});
await compressCommand.action!(ctx, '');
expect(ctx.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: expect.stringContaining('truncated'),
}),
expect.any(Number),
);
});

it('does NOT show a truncation notice when instructions fit under the cap', async () => {
const ctx = createMockCommandContext({
services: {
config: {
getGeminiClient: () =>
({
tryCompressChat: mockTryCompressChat,
}) as unknown as GeminiClient,
},
},
invocation: {
raw: '/compress short',
name: 'compress',
args: 'short',
},
});
await compressCommand.action!(ctx, '');
const infoCalls = (
ctx.ui.addItem as ReturnType<typeof vi.fn>
).mock.calls.filter(
(c) =>
(c[0] as { type?: MessageType }).type === MessageType.INFO &&
typeof (c[0] as { text?: string }).text === 'string' &&
(c[0] as { text: string }).text.includes('truncated'),
);
expect(infoCalls).toHaveLength(0);
});
});
});
42 changes: 40 additions & 2 deletions packages/cli/src/ui/commands/compressCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ import type { SlashCommand } from './types.js';
import { CommandKind } from './types.js';
import { t } from '../../i18n/index.js';

// Cap user-supplied compression instructions. The compression side-query has
// no input-truncation retry today, so an unbounded instruction string would
// inflate the side-query prompt and risk a PTL the compaction path can't
// recover from. 2000 chars is generous for human-typed focus directives
// without exposing that failure mode.
const MAX_COMPRESS_INSTRUCTIONS_CHARS = 2000;

export const compressCommand: SlashCommand = {
name: 'compress',
altNames: ['summarize'],
Expand Down Expand Up @@ -54,14 +61,39 @@ export const compressCommand: SlashCommand = {
};
}

const rawArgs = context.invocation?.args?.trim() ?? '';
const wasTruncated = rawArgs.length > MAX_COMPRESS_INSTRUCTIONS_CHARS;
const customInstructions = rawArgs
? rawArgs.slice(0, MAX_COMPRESS_INSTRUCTIONS_CHARS)
Comment thread
LaZzyMan marked this conversation as resolved.
Comment thread
LaZzyMan marked this conversation as resolved.
: undefined;
// Surface the silent cap so a user pasting an over-long focus directive
// knows their instructions were clipped mid-text rather than silently
// changing the summary's behaviour.
const truncationNotice = wasTruncated
? t('Compression instructions were truncated to {{max}} characters.', {
max: String(MAX_COMPRESS_INSTRUCTIONS_CHARS),
})
: undefined;

const doCompress = async () => {
const promptId = `compress-${Date.now()}`;
return await geminiClient.tryCompressChat(promptId, true);
return await geminiClient.tryCompressChat(
promptId,
true,
abortSignal,
customInstructions,
);
};

if (executionMode === 'acp') {
const messages = async function* () {
try {
if (truncationNotice) {
yield {
messageType: 'info' as const,
content: truncationNotice,
};
}
yield {
messageType: 'info' as const,
content: 'Compressing context...',
Expand Down Expand Up @@ -93,6 +125,12 @@ export const compressCommand: SlashCommand = {

try {
if (executionMode === 'interactive') {
if (truncationNotice) {
ui.addItem(
{ type: MessageType.INFO, text: truncationNotice },
Date.now(),
);
}
ui.setPendingItem(pendingMessage);
}

Expand Down Expand Up @@ -140,7 +178,7 @@ export const compressCommand: SlashCommand = {
return {
type: 'message',
messageType: 'info',
content: `Context compressed (${compressed.originalTokenCount} -> ${compressed.newTokenCount}).`,
content: `${truncationNotice ? `${truncationNotice} ` : ''}Context compressed (${compressed.originalTokenCount} -> ${compressed.newTokenCount}).`,
};
} catch (e) {
// If cancelled via ESC, don't show error — cancelSlashCommand already handled UI
Expand Down
33 changes: 32 additions & 1 deletion packages/core/src/core/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1930,7 +1930,38 @@ describe('Gemini Client (client.ts)', () => {

await client.tryCompressChat('p1', true, signal);

expect(tryCompress).toHaveBeenCalledWith('p1', 'the-model', true, signal);
// 5th arg is the `options` bag for `customInstructions` plumbing;
// omitted here means undefined which is the correct contract.
expect(tryCompress).toHaveBeenCalledWith(
'p1',
'the-model',
true,
signal,
undefined,
);
});

it('forwards customInstructions through the options bag when supplied', async () => {
const tryCompress = vi.fn().mockResolvedValue({
originalTokenCount: 0,
newTokenCount: 0,
compressionStatus: CompressionStatus.NOOP,
});
client['chat'] = {
tryCompress,
getHistory: vi.fn().mockReturnValue([]),
} as unknown as GeminiChat;
vi.mocked(mockConfig.getModel).mockReturnValue('the-model');

await client.tryCompressChat('p1', true, undefined, 'focus on auth bug');

expect(tryCompress).toHaveBeenCalledWith(
'p1',
'the-model',
true,
undefined,
{ customInstructions: 'focus on auth bug' },
);
});

it('flips forceFullIdeContext on a successful compression', async () => {
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/core/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2141,6 +2141,7 @@ export class GeminiClient {
prompt_id: string,
force: boolean = false,
signal?: AbortSignal,
customInstructions?: string,
): Promise<ChatCompressionInfo> {
const previousSessionStartContext = this.lastSessionStartContext;
const previousSessionStartSource = this.lastSessionStartSource;
Expand All @@ -2149,6 +2150,7 @@ export class GeminiClient {
this.config.getModel(),
force,
signal,
customInstructions ? { customInstructions } : undefined,
);
if (info.compressionStatus === CompressionStatus.COMPRESSED) {
const chat = this.getChat();
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/core/geminiChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,13 @@ interface TryCompressOptions {
* post-compression guards that may roll the in-memory chat state back.
*/
deferChatCompressionRecord?: boolean;
/**
* Forwarded to the compression side-query system prompt. Sourced from
* `/compress <text>` invocation arg; appended after the base prompt as
* an `Additional Instructions:` block so the summary model can focus
* on the user's stated concern.
*/
customInstructions?: string;
}

const INVALID_CONTENT_RETRY_OPTIONS: ContentRetryOptions = {
Expand Down Expand Up @@ -1420,6 +1427,7 @@ export class GeminiChat {
pendingUserMessage: options?.pendingUserMessage,
precomputedEffectiveTokens: options?.precomputedEffectiveTokens,
trigger: options?.trigger,
customInstructions: options?.customInstructions,
signal,
});

Expand Down
Loading
Loading