Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { BasePromptElementProps, PrioritizedList, PromptElement, PromptMetadata,
import { BudgetExceededError } from '@vscode/prompt-tsx/dist/base/materialized';
import { ChatMessage } from '@vscode/prompt-tsx/dist/base/output/rawTypes';
import type { ChatResponsePart, ChatResultPromptTokenDetail, LanguageModelToolInformation, NotebookDocument, Progress } from 'vscode';
import { IChatHookService, PreCompactHookInput } from '../../../../platform/chat/common/chatHookService';
import { IChatHookService, PreCompactHookInput, PreCompactHookOutput } from '../../../../platform/chat/common/chatHookService';
import { ChatFetchResponseType, ChatLocation, ChatResponse, FetchSuccess } from '../../../../platform/chat/common/commonTypes';
import { IHistoricalTurn, ISessionTranscriptService } from '../../../../platform/chat/common/sessionTranscriptService';
import { ConfigKey, IConfigurationService } from '../../../../platform/configuration/common/configurationService';
Expand Down Expand Up @@ -165,7 +165,7 @@ export class ConversationHistorySummarizationPrompt extends PromptElement<Conver
{SummaryPrompt}
{this.props.summarizationInstructions && <>
<br /><br />
## Additional instructions from the user:<br />
## Additional summarization instructions:<br />
{this.props.summarizationInstructions}
</>}
</SystemMessage>
Expand Down Expand Up @@ -585,13 +585,31 @@ class ConversationHistorySummarizer {
) { }

async summarizeHistory(): Promise<{ summary: string; toolCallRoundId: string; thinking?: ThinkingData; usage?: APIUsage; promptTokenDetails?: readonly ChatResultPromptTokenDetail[]; model?: string; summarizationMode?: string; numRounds?: number; numRoundsSinceLastSummarization?: number; durationMs?: number }> {
// Execute pre-compact hook before summarization to allow hooks to archive transcripts or perform cleanup
await this.executePreCompactHook();
// Execute pre-compact hook to allow hooks to archive transcripts or provide additional context
const hookAdditionalContext = await this.executePreCompactHook();

// Just a function for test to create props and call this
const propsInfo = this.instantiationService.createInstance(SummarizedConversationHistoryPropsBuilder).getProps(this.props);

const summaryPromise = this.getSummaryWithFallback(propsInfo);
// Merge hook context into summarization instructions, labeling sources separately
const effectiveSummarizationInstructions = hookAdditionalContext
? [
propsInfo.props.summarizationInstructions
? `User-provided additional instructions:\n${propsInfo.props.summarizationInstructions}`
: undefined,
`Additional instructions from hooks:\n${hookAdditionalContext}`,
].filter(value => !!value).join('\n\n')
: propsInfo.props.summarizationInstructions;

const effectivePropsInfo = effectiveSummarizationInstructions !== propsInfo.props.summarizationInstructions ? {
...propsInfo,
props: {
...propsInfo.props,
summarizationInstructions: effectiveSummarizationInstructions,
}
} : propsInfo;

const summaryPromise = this.getSummaryWithFallback(effectivePropsInfo);
this.progress?.report(new ChatResponseProgressPart2(l10n.t('Compacting conversation...'), async () => {
try {
await summaryPromise;
Expand Down Expand Up @@ -639,27 +657,39 @@ class ConversationHistorySummarizer {
/**
* Executes the PreCompact hook before summarization starts.
* This gives hook scripts a chance to archive the transcript or perform cleanup
* before the conversation is compacted.
* before the conversation is compacted, and optionally provide additional context
* to include in the summarization instructions.
*
* @returns Additional context from hooks to merge into summarization instructions, or undefined.
*/
private async executePreCompactHook(): Promise<void> {
private async executePreCompactHook(): Promise<string | undefined> {
const hooks = this.props.promptContext.request?.hooks;
if (!hooks) {
return;
return undefined;
}

try {
const results = await this.chatHookService.executeHook('PreCompact', hooks, {
trigger: 'auto',
} satisfies PreCompactHookInput, this.props.promptContext.conversation?.sessionId, this.token ?? CancellationToken.None);

const allAdditionalContext: string[] = [];
for (const result of results) {
if (result.resultKind === 'error') {
const errorMessage = typeof result.output === 'string' ? result.output : 'Unknown error';
this.logService.error(`[ConversationHistorySummarizer] PreCompact hook error: ${errorMessage}`);
} else if (result.resultKind === 'success' && result.output && typeof result.output === 'object') {
const output = result.output as PreCompactHookOutput;
if (output.hookSpecificOutput?.additionalContext) {
allAdditionalContext.push(output.hookSpecificOutput.additionalContext);
}
}
Comment on lines 678 to 686
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

executePreCompactHook manually iterates hook results and only logs resultKind === 'error'. This drops warning results (and any stopReason/warningMessage) on the floor, unlike other hook call sites (e.g. SessionStart/SubagentStart) that use processHookResults(..., ignoreErrors: true) to consistently handle warnings/errors while still proceeding. Consider switching to processHookResults here (with outputStream: undefined and ignoreErrors: true) and collecting hookSpecificOutput.additionalContext in onSuccess.

Suggested change
if (result.resultKind === 'error') {
const errorMessage = typeof result.output === 'string' ? result.output : 'Unknown error';
this.logService.error(`[ConversationHistorySummarizer] PreCompact hook error: ${errorMessage}`);
} else if (result.resultKind === 'success' && result.output && typeof result.output === 'object') {
const output = result.output as PreCompactHookOutput;
if (output.hookSpecificOutput?.additionalContext) {
allAdditionalContext.push(output.hookSpecificOutput.additionalContext);
}
}
if (result.resultKind === 'success' && result.output && typeof result.output === 'object') {
const output = result.output as PreCompactHookOutput;
if (output.hookSpecificOutput?.additionalContext) {
allAdditionalContext.push(output.hookSpecificOutput.additionalContext);
}
}
const warningMessage = 'warningMessage' in result && typeof result.warningMessage === 'string' ? result.warningMessage : undefined;
const stopReason = 'stopReason' in result && typeof result.stopReason === 'string' ? result.stopReason : undefined;
if (result.resultKind === 'error') {
const errorMessage = typeof result.output === 'string' ? result.output : 'Unknown error';
this.logService.error(`[ConversationHistorySummarizer] PreCompact hook error: ${errorMessage}`);
if (warningMessage) {
this.logService.warn(`[ConversationHistorySummarizer] PreCompact hook warning: ${warningMessage}`);
}
if (stopReason) {
this.logService.warn(`[ConversationHistorySummarizer] PreCompact hook stop reason: ${stopReason}`);
}
} else if (result.resultKind === 'warning') {
const resultWarningMessage = typeof result.output === 'string' ? result.output : warningMessage ?? 'Unknown warning';
this.logService.warn(`[ConversationHistorySummarizer] PreCompact hook warning: ${resultWarningMessage}`);
if (stopReason) {
this.logService.warn(`[ConversationHistorySummarizer] PreCompact hook stop reason: ${stopReason}`);
}
}

Copilot uses AI. Check for mistakes.
}

return allAdditionalContext.length > 0 ? allAdditionalContext.join('\n') : undefined;
} catch (error) {
this.logService.error('[ConversationHistorySummarizer] Error executing PreCompact hook', error);
return undefined;
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { Raw } from '@vscode/prompt-tsx';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import type { ChatHookResult } from 'vscode';
import { IChatHookService, PreCompactHookInput } from '../../../../../platform/chat/common/chatHookService';
import { IChatMLFetcher } from '../../../../../platform/chat/common/chatMLFetcher';
import { ChatLocation } from '../../../../../platform/chat/common/commonTypes';
import { StreamingMockChatMLFetcher } from '../../../../../platform/chat/test/common/streamingMockChatMLFetcher';
import { MockEndpoint } from '../../../../../platform/endpoint/test/node/mockEndpoint';
import { messageToMarkdown } from '../../../../../platform/log/common/messageStringify';
import { IChatEndpoint } from '../../../../../platform/networking/common/networking';
import { ITestingServicesAccessor } from '../../../../../platform/test/node/services';
import { TestWorkspaceService } from '../../../../../platform/test/node/testWorkspaceService';
import { IWorkspaceService } from '../../../../../platform/workspace/common/workspaceService';
import { createTextDocumentData } from '../../../../../util/common/test/shims/textDocument';
import { DisposableStore } from '../../../../../util/vs/base/common/lifecycle';
import { URI } from '../../../../../util/vs/base/common/uri';
import { SyncDescriptor } from '../../../../../util/vs/platform/instantiation/common/descriptors';
import { IInstantiationService } from '../../../../../util/vs/platform/instantiation/common/instantiation';
import { LanguageModelTextPart, LanguageModelToolResult } from '../../../../../vscodeTypes';
import { MockChatHookService } from '../../../../intents/test/node/toolCallingLoopHooks.spec';
import { ChatVariablesCollection } from '../../../../prompt/common/chatVariablesCollection';
Comment on lines +24 to +26
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Avoid importing MockChatHookService from another *.spec.ts file. Importing a spec as a module can cause that spec’s tests to be registered/executed as a dependency (and then again when the runner loads it as a test file), which can lead to duplicated suites and flaky ordering. Move MockChatHookService into a dedicated test helper module (non-*.spec.*) and import it from both specs.

Copilot uses AI. Check for mistakes.
import { Conversation, Turn } from '../../../../prompt/common/conversation';
import { IBuildPromptContext } from '../../../../prompt/common/intents';
import { ToolCallRound } from '../../../../prompt/common/toolCallRound';
import { createExtensionUnitTestingServices } from '../../../../test/node/services';
import { ToolName } from '../../../../tools/common/toolNames';
import { PromptRenderer } from '../../base/promptRenderer';
import { SummarizedConversationHistory, SummarizedConversationHistoryMetadata } from '../summarizedConversationHistory';

const fileTsUri = URI.file('/workspace/file.ts');

describe('PreCompact Hook Integration', () => {
let disposables: DisposableStore;
let mockHookService: MockChatHookService;
let streamingFetcher: StreamingMockChatMLFetcher;
let hookAccessor: ITestingServicesAccessor;

beforeEach(() => {
disposables = new DisposableStore();
mockHookService = new MockChatHookService();
streamingFetcher = new StreamingMockChatMLFetcher();
streamingFetcher.setStreamingLines(['summarized conversation']);

const testDoc = createTextDocumentData(fileTsUri, 'line 1\nline 2\n\nline 4\nline 5', 'ts').document;
const services = disposables.add(createExtensionUnitTestingServices());
services.define(IWorkspaceService, new SyncDescriptor(
TestWorkspaceService,
[
[URI.file('/workspace')],
[testDoc]
]
));
services.define(IChatHookService, mockHookService);
services.define(IChatMLFetcher, streamingFetcher);
hookAccessor = services.createTestingAccessor();
});

afterEach(() => {
disposables.dispose();
});

function createHookTestProps() {
const instaService = hookAccessor.get(IInstantiationService);
const endpoint = instaService.createInstance(MockEndpoint, undefined);

const toolCallRounds = [
new ToolCallRound('ok', [{
id: 'tooluse_1',
name: ToolName.EditFile,
arguments: JSON.stringify({ filePath: fileTsUri.fsPath, code: 'console.log("hi")' }),
}]),
new ToolCallRound('ok 2', [{
id: 'tooluse_2',
name: ToolName.EditFile,
arguments: JSON.stringify({ filePath: fileTsUri.fsPath, code: 'console.log("hello")' }),
}]),
];
const turn = new Turn('turnId', { type: 'user', message: 'hello' });
const testConversation = new Conversation('sessionId', [turn]);

const promptContext: IBuildPromptContext = {
chatVariables: new ChatVariablesCollection([]),
history: [],
query: 'edit this file',
toolCallRounds,
toolCallResults: {
'tooluse_1': new LanguageModelToolResult([new LanguageModelTextPart('success')]),
'tooluse_2': new LanguageModelToolResult([new LanguageModelTextPart('success')]),
},
tools: {
availableTools: [],
toolInvocationToken: null as never,
toolReferences: [],
},
conversation: testConversation,
request: { hooks: { PreCompact: [] } } as any,
};

return { instaService, endpoint, promptContext };
}

function renderSummarization(instaService: IInstantiationService, endpoint: IChatEndpoint, promptContext: IBuildPromptContext) {
return PromptRenderer.create(instaService, endpoint, SummarizedConversationHistory, {
priority: 1,
endpoint,
location: ChatLocation.Panel,
promptContext,
maxToolResultLength: Infinity,
triggerSummarize: true,
});
}

describe('hook execution conditions', () => {
it('should call PreCompact hook with trigger auto during summarization', async () => {
const { instaService, endpoint, promptContext } = createHookTestProps();
await renderSummarization(instaService, endpoint, promptContext).render();

const preCompactCalls = mockHookService.getCallsForHook('PreCompact' as any);
expect(preCompactCalls).toHaveLength(1);
expect((preCompactCalls[0].input as PreCompactHookInput).trigger).toBe('auto');
});

it('should not call PreCompact hook when promptContext has no request hooks', async () => {
const { instaService, endpoint, promptContext } = createHookTestProps();
// Create new context without request to simulate background compaction without hooks
const { request: _, ...contextWithoutRequest } = promptContext;
const noHooksContext: IBuildPromptContext = { ...contextWithoutRequest };

await renderSummarization(instaService, endpoint, noHooksContext).render();

const preCompactCalls = mockHookService.getCallsForHook('PreCompact' as any);
expect(preCompactCalls).toHaveLength(0);
});
});

describe('additionalContext injection', () => {
it('should include hook additionalContext in summarization prompt sent to LLM', async () => {
mockHookService.setHookResults('PreCompact' as any, [{
resultKind: 'success' as const,
output: {
hookSpecificOutput: { additionalContext: 'Focus on preserving database query details' },
},
} as ChatHookResult]);

const { instaService, endpoint, promptContext } = createHookTestProps();
await renderSummarization(instaService, endpoint, promptContext).render();

expect(streamingFetcher.callCount).toBeGreaterThan(0);
const capturedMessages = streamingFetcher.capturedOptions[0]?.messages;
expect(capturedMessages).toBeDefined();
const allSystemContent = capturedMessages!
.filter(m => m.role === Raw.ChatRole.System)
.map(m => messageToMarkdown(m))
.join('\n');
expect(allSystemContent).toContain('Additional summarization instructions:');
expect(allSystemContent).toContain('Additional instructions from hooks:');
expect(allSystemContent).toContain('Focus on preserving database query details');
});

it('should concatenate additionalContext from multiple hook results', async () => {
mockHookService.setHookResults('PreCompact' as any, [
{
resultKind: 'success' as const,
output: {
hookSpecificOutput: { additionalContext: 'Keep file paths' },
},
} as ChatHookResult,
{
resultKind: 'success' as const,
output: {
hookSpecificOutput: { additionalContext: 'Remember error messages' },
},
} as ChatHookResult,
]);

const { instaService, endpoint, promptContext } = createHookTestProps();
await renderSummarization(instaService, endpoint, promptContext).render();

const capturedMessages = streamingFetcher.capturedOptions[0]?.messages;
const allSystemContent = capturedMessages!
.filter(m => m.role === Raw.ChatRole.System)
.map(m => messageToMarkdown(m))
.join('\n');
expect(allSystemContent).toContain('Additional instructions from hooks:');
expect(allSystemContent).toContain('Keep file paths');
expect(allSystemContent).toContain('Remember error messages');
});

it('should not include additional instructions section when hook returns no additionalContext', async () => {
mockHookService.setHookResults('PreCompact' as any, [{
resultKind: 'success' as const,
output: {},
} as ChatHookResult]);

const { instaService, endpoint, promptContext } = createHookTestProps();
await renderSummarization(instaService, endpoint, promptContext).render();

expect(streamingFetcher.callCount).toBeGreaterThan(0);
const capturedMessages = streamingFetcher.capturedOptions[0]?.messages;
expect(capturedMessages).toBeDefined();
const allSystemContent = capturedMessages!
.filter(m => m.role === Raw.ChatRole.System)
.map(m => messageToMarkdown(m))
.join('\n');
expect(allSystemContent).not.toContain('Additional summarization instructions:');
});
});

describe('error handling', () => {
it('should complete summarization when hook throws an error', async () => {
mockHookService.setHookError('PreCompact' as any, new Error('Hook script failed'));

const { instaService, endpoint, promptContext } = createHookTestProps();
const result = await renderSummarization(instaService, endpoint, promptContext).render();

const summaryMeta = result.metadata.get(SummarizedConversationHistoryMetadata);
expect(summaryMeta).toBeDefined();
expect(summaryMeta!.text).toContain('summarized conversation');
});

it('should not include additional instructions when hook returns error result', async () => {
mockHookService.setHookResults('PreCompact' as any, [{
resultKind: 'error' as const,
output: 'Hook execution failed',
} as ChatHookResult]);

const { instaService, endpoint, promptContext } = createHookTestProps();
await renderSummarization(instaService, endpoint, promptContext).render();

expect(streamingFetcher.callCount).toBeGreaterThan(0);
const capturedMessages = streamingFetcher.capturedOptions[0]?.messages;
const allSystemContent = capturedMessages!
.filter(m => m.role === Raw.ChatRole.System)
.map(m => messageToMarkdown(m))
.join('\n');
expect(allSystemContent).not.toContain('Additional summarization instructions:');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,50 @@ suite('Agent Summarization', () => {
&& successMeta!.outcome !== 'success';
expect(shouldSkipAfterSuccess).toBe(false);
});

test('summarization prompt renders additional instructions from summarizationInstructions prop', async () => {
const { instaService, endpoint, promptContext } = createSummarizationTestContext();
const propsInfo = instaService.createInstance(SummarizedConversationHistoryPropsBuilder).getProps({
priority: 1,
endpoint,
location: ChatLocation.Panel,
promptContext,
maxToolResultLength: Infinity,
summarizationInstructions: 'Please preserve all file paths and line numbers',
});
const renderer = PromptRenderer.create(instaService, endpoint, ConversationHistorySummarizationPrompt, {
...propsInfo.props,
simpleMode: false,
});
const r = await renderer.render();
const systemContent = r.messages
.filter(m => m.role === Raw.ChatRole.System)
.map(m => messageToMarkdown(m))
.join('\n');
expect(systemContent).toContain('Additional summarization instructions:');
expect(systemContent).toContain('Please preserve all file paths and line numbers');
});

test('summarization prompt does not include additional instructions section when summarizationInstructions is undefined', async () => {
const { instaService, endpoint, promptContext } = createSummarizationTestContext();
const propsInfo = instaService.createInstance(SummarizedConversationHistoryPropsBuilder).getProps({
priority: 1,
endpoint,
location: ChatLocation.Panel,
promptContext,
maxToolResultLength: Infinity,
});
const renderer = PromptRenderer.create(instaService, endpoint, ConversationHistorySummarizationPrompt, {
...propsInfo.props,
simpleMode: false,
});
const r = await renderer.render();
const systemContent = r.messages
.filter(m => m.role === Raw.ChatRole.System)
.map(m => messageToMarkdown(m))
.join('\n');
expect(systemContent).not.toContain('Additional summarization instructions:');
});
});

suite('extractInlineSummary', () => {
Expand Down
Loading
Loading