From 35e83ba6564b3ea786bc88dc8eaebb9ae4cb908a Mon Sep 17 00:00:00 2001 From: Bhavya U Date: Tue, 17 Mar 2026 23:06:21 -0700 Subject: [PATCH] feat: add conversation transcript lookup after summarization (#4475) * feat: add conversation transcript lookup after summarization After conversation history is compacted, inform the model it can look up the full pre-compaction transcript via read_file. The transcript is a JSONL file produced by ISessionTranscriptService. Key changes: - Add isTranscriptUri() to ISessionTranscriptService for read_file allowlisting - Allowlist transcript URIs in assertFileOkForTool and isFileExternalAndNeedsConfirmation - Lazily start transcript session in SummarizedConversationHistory before summarization runs (idempotent if hooks already started it) - After summarization, flush transcript and pass path to SummaryMessageElement which tells the model about the file - Gate behind ConfigKey.ConversationTranscriptLookup (ExperimentBased, default off) - Add setting in package.json preview section with onExp tag * fix: update transcript lookup instruction to use ToolName.ReadFile --- extensions/copilot/package.json | 9 +++ extensions/copilot/package.nls.json | 1 + .../vscode-node/sessionTranscriptService.ts | 9 +++ .../agent/summarizedConversationHistory.tsx | 68 ++++++++++++++++++- .../src/extension/tools/node/toolUtils.ts | 9 +++ .../chat/common/sessionTranscriptService.ts | 7 ++ .../common/configurationService.ts | 1 + 7 files changed, 102 insertions(+), 2 deletions(-) diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 5e8a8f6b191..d713a674872 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -3235,6 +3235,15 @@ "preview", "onExp" ] + }, + "github.copilot.chat.conversationTranscriptLookup.enabled": { + "type": "boolean", + "default": false, + "description": "%github.copilot.config.conversationTranscriptLookup.enabled%", + "tags": [ + "preview", + "onExp" + ] } } }, diff --git a/extensions/copilot/package.nls.json b/extensions/copilot/package.nls.json index 70d60c72e61..afb48707905 100644 --- a/extensions/copilot/package.nls.json +++ b/extensions/copilot/package.nls.json @@ -265,6 +265,7 @@ "github.copilot.config.editsNewNotebook.enabled": "Whether to enable the new notebook tool in Copilot Edits.", "github.copilot.config.notebook.inlineEditAgent.enabled": "Enable agent-like behavior from the notebook inline chat widget.", "github.copilot.config.summarizeAgentConversationHistory.enabled": "Whether to auto-compact agent conversation history once the context window is filled.", + "github.copilot.config.conversationTranscriptLookup.enabled": "When enabled, after conversation history is summarized the model is informed it can look up the full conversation transcript via read_file.", "github.copilot.tools.createNewWorkspace.name": "Create New Workspace", "github.copilot.tools.openEmptyFolder.name": "Open an empty folder as VS Code workspace", "github.copilot.tools.getProjectSetupInfo.name": "Get Project Setup Info", diff --git a/extensions/copilot/src/extension/chat/vscode-node/sessionTranscriptService.ts b/extensions/copilot/src/extension/chat/vscode-node/sessionTranscriptService.ts index cb67896de56..366c14a5990 100644 --- a/extensions/copilot/src/extension/chat/vscode-node/sessionTranscriptService.ts +++ b/extensions/copilot/src/extension/chat/vscode-node/sessionTranscriptService.ts @@ -14,6 +14,7 @@ import { IEnvService } from '../../../platform/env/common/envService'; import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext'; import { IFileSystemService, createDirectoryIfNotExists } from '../../../platform/filesystem/common/fileSystemService'; import { ILogService } from '../../../platform/log/common/logService'; +import { extUriBiasedIgnorePathCase } from '../../../util/vs/base/common/resources'; import { URI } from '../../../util/vs/base/common/uri'; import { generateUuid } from '../../../util/vs/base/common/uuid'; @@ -215,6 +216,14 @@ export class SessionTranscriptService implements ISessionTranscriptService { return this._activeSessions.get(sessionId)?.uri; } + isTranscriptUri(uri: URI): boolean { + const dir = this._getTranscriptsDir(); + if (!dir) { + return false; + } + return extUriBiasedIgnorePathCase.isEqualOrParent(uri, dir); + } + async cleanupOldTranscripts(maxRetained: number = DEFAULT_MAX_RETAINED): Promise { const dir = this._getTranscriptsDir(); if (!dir) { diff --git a/extensions/copilot/src/extension/prompts/node/agent/summarizedConversationHistory.tsx b/extensions/copilot/src/extension/prompts/node/agent/summarizedConversationHistory.tsx index e2221b4f5c9..4727a25dcd5 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/summarizedConversationHistory.tsx +++ b/extensions/copilot/src/extension/prompts/node/agent/summarizedConversationHistory.tsx @@ -10,6 +10,7 @@ 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 { 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'; import { isAnthropicFamily } from '../../../../platform/endpoint/common/chatModelCapabilities'; import { IEndpointProvider } from '../../../../platform/endpoint/common/endpointProvider'; @@ -248,7 +249,7 @@ class ConversationHistory extends PromptElement { } if (summaryForCurrentTurn) { - history.push(); + history.push(); return ( {history.reverse()} @@ -308,7 +309,7 @@ class ConversationHistory extends PromptElement { if (summaryForTurn) { // We have a summary for a tool call round that was part of this turn - turnComponents.push(); + turnComponents.push(); } else if (!turn.isContinuation) { turnComponents.push( | undefined, token: CancellationToken | undefined) { const promptContext = { ...this.props.promptContext }; let historyMetadata: SummarizedConversationHistoryMetadata | undefined; + const transcriptLookupEnabled = this.configurationService.getExperimentBasedConfig(ConfigKey.ConversationTranscriptLookup, this.experimentationService); + if (this.props.triggerSummarize) { + // If transcript lookup is enabled, lazily start the transcript session now + // (before summarization) so it captures the full pre-compaction conversation. + // startSession is idempotent — if hooks already started it, this is a no-op. + if (transcriptLookupEnabled) { + await this.ensureTranscriptSession(); + } + const summarizer = this.instantiationService.createInstance(ConversationHistorySummarizer, this.props, sizing, progress, token); const summResult = await summarizer.summarizeHistory(); if (summResult) { @@ -442,15 +457,62 @@ export class SummarizedConversationHistory extends PromptElement {historyMetadata && } ; } + /** + * Lazily starts a transcript session with the full conversation history. + * This is called just before summarization so that the transcript file + * contains the complete pre-compaction conversation. If a session was + * already started (e.g. by hooks), this is a no-op. + */ + private async ensureTranscriptSession(): Promise { + const sessionId = this.props.promptContext.conversation?.sessionId; + if (!sessionId) { + return; + } + + // Build IHistoricalTurn[] from the prompt context's Turn[] history + const history: IHistoricalTurn[] = this.props.promptContext.history.map(turn => ({ + userMessage: turn.request.message, + timestamp: turn.startTime, + rounds: turn.rounds.map(round => ({ + response: round.response, + toolCalls: round.toolCalls.map(tc => ({ + name: tc.name, + arguments: tc.arguments, + id: tc.id, + })), + reasoningText: round.thinking + ? (Array.isArray(round.thinking.text) ? round.thinking.text.join('') : round.thinking.text) + : undefined, + timestamp: round.timestamp, + })), + })); + + await this.sessionTranscriptService.startSession(sessionId, undefined, history.length > 0 ? history : undefined); + } + private addSummaryToHistory(summary: string, toolCallRoundId: string, thinking?: ThinkingData): void { const round = this.props.promptContext.toolCallRounds?.find(round => round.id === toolCallRoundId); if (round) { @@ -918,6 +980,7 @@ export class SummarizedConversationHistoryPropsBuilder { interface SummaryMessageProps extends BasePromptElementProps { readonly summaryText: string; readonly endpoint: IChatEndpoint; + readonly transcriptPath?: string; } class SummaryMessageElement extends PromptElement { @@ -926,6 +989,7 @@ class SummaryMessageElement extends PromptElement { {this.props.summaryText} + {this.props.transcriptPath && <>
If you need specific details from before compaction (such as exact code snippets, error messages, tool results, or content you previously generated), use the {ToolName.ReadFile} tool to look up the full uncompacted conversation transcript at: {this.props.transcriptPath}} {this.props.endpoint.family === 'gpt-4.1' && } diff --git a/extensions/copilot/src/extension/tools/node/toolUtils.ts b/extensions/copilot/src/extension/tools/node/toolUtils.ts index 6e9b8dfe42d..fe8fa8d4a1a 100644 --- a/extensions/copilot/src/extension/tools/node/toolUtils.ts +++ b/extensions/copilot/src/extension/tools/node/toolUtils.ts @@ -6,6 +6,7 @@ import { PromptElement, PromptPiece } from '@vscode/prompt-tsx'; import type * as vscode from 'vscode'; import { IChatDebugFileLoggerService } from '../../../platform/chat/common/chatDebugFileLoggerService'; +import { ISessionTranscriptService } from '../../../platform/chat/common/sessionTranscriptService'; import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService'; import { ICustomInstructionsService, IInstructionIndexFile } from '../../../platform/customInstructions/common/customInstructionsService'; import { IFileSystemService } from '../../../platform/filesystem/common/fileSystemService'; @@ -171,6 +172,7 @@ export async function assertFileOkForTool(accessor: ServicesAccessor, uri: URI, const diskSessionResources = accessor.get(IChatDiskSessionResources); const configurationService = accessor.get(IConfigurationService); const chatDebugFileLogger = accessor.get(IChatDebugFileLoggerService); + const sessionTranscriptService = accessor.get(ISessionTranscriptService); await assertFileNotContentExcluded(accessor, uri); @@ -194,6 +196,9 @@ export async function assertFileOkForTool(accessor: ServicesAccessor, uri: URI, if (chatDebugFileLogger.isDebugLogUri(normalizedUri)) { return; } + if (sessionTranscriptService.isTranscriptUri(normalizedUri)) { + return; + } if (await isExternalInstructionsFile(normalizedUri, customInstructionsService, buildPromptContext)) { return; } @@ -269,6 +274,7 @@ export async function isFileExternalAndNeedsConfirmation(accessor: ServicesAcces const configurationService = accessor.get(IConfigurationService); const fileSystemService = accessor.get(IFileSystemService); const chatDebugFileLogger = accessor.get(IChatDebugFileLoggerService); + const sessionTranscriptService = accessor.get(ISessionTranscriptService); const normalizedUri = normalizePath(uri); @@ -291,6 +297,9 @@ export async function isFileExternalAndNeedsConfirmation(accessor: ServicesAcces if (chatDebugFileLogger.isDebugLogUri(normalizedUri)) { return false; } + if (sessionTranscriptService.isTranscriptUri(normalizedUri)) { + return false; + } if (tabsAndEditorsService.tabs.some(tab => isEqual(tab.uri, uri))) { return false; } diff --git a/extensions/copilot/src/platform/chat/common/sessionTranscriptService.ts b/extensions/copilot/src/platform/chat/common/sessionTranscriptService.ts index d62e255bfdc..50e2714592e 100644 --- a/extensions/copilot/src/platform/chat/common/sessionTranscriptService.ts +++ b/extensions/copilot/src/platform/chat/common/sessionTranscriptService.ts @@ -242,6 +242,12 @@ export interface ISessionTranscriptService { * keeping at most `maxRetained` most-recent ended sessions. */ cleanupOldTranscripts(maxRetained?: number): Promise; + + /** + * Check whether a URI is under the transcripts storage directory. + * Used by {@link assertFileOkForTool} to allowlist tool reads. + */ + isTranscriptUri(uri: URI): boolean; } export class NullSessionTranscriptService implements ISessionTranscriptService { @@ -258,4 +264,5 @@ export class NullSessionTranscriptService implements ISessionTranscriptService { async endSession(): Promise { } getTranscriptPath(): URI | undefined { return undefined; } async cleanupOldTranscripts(): Promise { } + isTranscriptUri(): boolean { return false; } } diff --git a/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts index 23eb78d266e..0fc2583408f 100644 --- a/extensions/copilot/src/platform/configuration/common/configurationService.ts +++ b/extensions/copilot/src/platform/configuration/common/configurationService.ts @@ -933,6 +933,7 @@ export namespace ConfigKey { export const NewWorkspaceCreationAgentEnabled = defineSetting('chat.newWorkspaceCreation.enabled', ConfigType.Simple, true); export const NewWorkspaceUseContext7 = defineSetting('chat.newWorkspace.useContext7', ConfigType.Simple, false); export const SummarizeAgentConversationHistory = defineSetting('chat.summarizeAgentConversationHistory.enabled', ConfigType.Simple, true); + export const ConversationTranscriptLookup = defineSetting('chat.conversationTranscriptLookup.enabled', ConfigType.ExperimentBased, false); export const BackgroundCompaction = defineSetting('chat.backgroundCompaction', ConfigType.ExperimentBased, false); export const VirtualToolThreshold = defineSetting('chat.virtualTools.threshold', ConfigType.ExperimentBased, HARD_TOOL_LIMIT); export const CurrentEditorAgentContext = defineSetting('chat.agent.currentEditorContext.enabled', ConfigType.Simple, true);