diff --git a/package.json b/package.json index b4557f9dd..c16a9cdb4 100644 --- a/package.json +++ b/package.json @@ -1652,15 +1652,6 @@ ] } ] - }, - { - "id": "github.copilot.chatReplay", - "name": "chatReplay", - "fullName": "Chat Replay", - "when": "debugType == 'vscode-chat-replay'", - "locations": [ - "panel" - ] } ], "languageModelChatProviders": [ @@ -4056,6 +4047,13 @@ "description": "Complete a security review of the pending changes on the current branch" } ] + }, + { + "id": "chat-replay", + "type": "chat-replay", + "name": "replay", + "displayName": "Chat Replay", + "description": "Replay chat sessions from JSON files" } ], "debuggers": [ @@ -4267,4 +4265,4 @@ "string_decoder": "npm:string_decoder@1.2.0", "node-gyp": "npm:node-gyp@10.3.1" } -} \ No newline at end of file +} diff --git a/src/extension/conversation/vscode-node/chatParticipants.ts b/src/extension/conversation/vscode-node/chatParticipants.ts index d907f717a..295b59cae 100644 --- a/src/extension/conversation/vscode-node/chatParticipants.ts +++ b/src/extension/conversation/vscode-node/chatParticipants.ts @@ -87,7 +87,6 @@ class ChatAgents implements IDisposable { this._disposables.add(this.registerVSCodeAgent()); this._disposables.add(this.registerTerminalAgent()); this._disposables.add(this.registerTerminalPanelAgent()); - this._disposables.add(this.registerReplayAgent()); } private createAgent(name: string, defaultIntentIdOrGetter: IntentOrGetter, options?: { id?: string }): vscode.ChatParticipant { @@ -250,13 +249,6 @@ Learn more about [GitHub Copilot](https://docs.github.com/copilot/using-github-c return defaultAgent; } - private registerReplayAgent(): IDisposable { - const defaultAgent = this.createAgent('chatReplay', Intent.ChatReplay); - defaultAgent.iconPath = new vscode.ThemeIcon('copilot'); - - return defaultAgent; - } - private getChatParticipantHandler(id: string, name: string, defaultIntentIdOrGetter: IntentOrGetter, onRequestPaused: Event): vscode.ChatExtendedRequestHandler { return async (request, context, stream, token): Promise => { diff --git a/src/extension/intents/node/allIntents.ts b/src/extension/intents/node/allIntents.ts index 12a479742..99378376f 100644 --- a/src/extension/intents/node/allIntents.ts +++ b/src/extension/intents/node/allIntents.ts @@ -8,7 +8,6 @@ import { SyncDescriptor } from '../../../util/vs/platform/instantiation/common/d import { IntentRegistry } from '../../prompt/node/intentRegistry'; import { AgentIntent } from './agentIntent'; import { AskAgentIntent } from './askAgentIntent'; -import { ChatReplayIntent } from './chatReplayIntent'; import { InlineDocIntent } from './docIntent'; import { EditCodeIntent } from './editCodeIntent'; import { EditCode2Intent } from './editCodeIntent2'; @@ -54,6 +53,5 @@ IntentRegistry.setIntents([ new SyncDescriptor(SearchPanelIntent), new SyncDescriptor(SearchKeywordsIntent), new SyncDescriptor(AskAgentIntent), - new SyncDescriptor(NotebookEditorIntent), - new SyncDescriptor(ChatReplayIntent) + new SyncDescriptor(NotebookEditorIntent) ]); diff --git a/src/extension/intents/node/chatReplayIntent.ts b/src/extension/intents/node/chatReplayIntent.ts index a71fd18f4..0a541a20e 100644 --- a/src/extension/intents/node/chatReplayIntent.ts +++ b/src/extension/intents/node/chatReplayIntent.ts @@ -70,7 +70,7 @@ export class ChatReplayIntent implements IIntent { break; case 'toolCall': { - replay.setToolResult(step.id, step.results); + const result = await this.toolsService.invokeTool(ToolName.ToolReplay, { toolInvocationToken: toolToken, diff --git a/src/extension/replay/common/chatReplayResponses.ts b/src/extension/replay/common/chatReplayResponses.ts index 6f6f200c4..b5f51fd19 100644 --- a/src/extension/replay/common/chatReplayResponses.ts +++ b/src/extension/replay/common/chatReplayResponses.ts @@ -41,7 +41,7 @@ type Request = { id: string; line: number; prompt: string; - result: string; + result: string | string[]; } export type ChatStep = UserQuery | Request | ToolStep; @@ -49,7 +49,6 @@ export type ChatStep = UserQuery | Request | ToolStep; export class ChatReplayResponses { private pendingRequests: DeferredPromise[] = []; private responses: (ChatStep | 'finished')[] = []; - private toolResults: Map = new Map(); public static instance: ChatReplayResponses; @@ -88,14 +87,6 @@ export class ChatReplayResponses { return deferred.p; } - public setToolResult(id: string, result: string[]): void { - this.toolResults.set(id, result); - } - - public getToolResult(id: string): string[] | undefined { - return this.toolResults.get(id); - } - public markDone(): void { while (this.pendingRequests.length > 0) { const waiter = this.pendingRequests.shift(); diff --git a/src/extension/replay/common/replayParser.ts b/src/extension/replay/common/replayParser.ts new file mode 100644 index 000000000..502f88724 --- /dev/null +++ b/src/extension/replay/common/replayParser.ts @@ -0,0 +1,159 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as fs from 'node:fs'; +import { ChatStep } from './chatReplayResponses'; + +export interface ReplayData { + chatSteps: ChatStep[]; + filePath: string; +} + +/** + * Parses a replay file and returns the chat steps with line numbers + * @param filePath The absolute path to the replay file + * @returns The parsed replay data containing chat steps and file path + */ +export function parseReplayFromFile(filePath: string): ReplayData { + if (!fs.existsSync(filePath)) { + throw new Error(`Replay file not found: ${filePath}`); + } + + try { + const content = fs.readFileSync(filePath, 'utf8'); + const chatSteps = parseReplayContent(content); + return { + chatSteps, + filePath + }; + } catch (error) { + throw new Error(`Failed to parse replay file ${filePath}: ${error}`); + } +} + +/** + * Parses a replay file from a session ID (base64 encoded file path) + * @param sessionId The session ID (base64 encoded file path, optionally prefixed with 'debug:') + * @returns The parsed replay data containing chat steps and file path + */ +export function parseReplayFromSessionId(sessionId: string): ReplayData { + const filePath = getFilePathFromSessionId(sessionId); + if (!filePath) { + throw new Error(`Invalid session ID: ${sessionId}`); + } + return parseReplayFromFile(filePath); +} + +/** + * Converts a session ID to a file path + * @param sessionId The session ID (base64 encoded file path, optionally prefixed with 'debug:') + * @returns The decoded file path, or undefined if the session ID is invalid + */ +export function getFilePathFromSessionId(sessionId: string): string | undefined { + try { + // Handle debug session IDs by removing the debug prefix + const actualSessionId = sessionId.startsWith('debug:') ? sessionId.substring(6) : sessionId; + return Buffer.from(actualSessionId, 'base64').toString('utf8'); + } catch { + return undefined; + } +} + +/** + * Creates a session ID from a file path + * @param filePath The absolute path to the replay file + * @param isDebugSession Whether this is a debug session (adds 'debug:' prefix) + * @returns The base64 encoded session ID + */ +export function createSessionIdFromFilePath(filePath: string): string { + return Buffer.from(filePath).toString('base64'); +} + +/** + * Parses the replay content and assigns line numbers to each step + * @param content The raw replay file content + * @returns Array of chat steps with line numbers + */ +function parseReplayContent(content: string): ChatStep[] { + const parsed = JSON.parse(content); + const prompts = (parsed.prompts && Array.isArray(parsed.prompts) ? parsed.prompts : [parsed]) as { [key: string]: any }[]; + + if (prompts.filter(p => !p.prompt).length) { + throw new Error('Invalid replay content: expected a prompt object or an array of prompts in the base JSON structure.'); + } + + const steps: ChatStep[] = []; + for (const prompt of prompts) { + steps.push(...parsePrompt(prompt)); + } + + // Assign line numbers based on content + assignLineNumbers(steps, content); + + return steps; +} + +/** + * Parses a single prompt object into chat steps + */ +function parsePrompt(prompt: { [key: string]: any }): ChatStep[] { + const steps: ChatStep[] = []; + steps.push({ + kind: 'userQuery', + query: prompt.prompt, + line: 0, + }); + + for (const log of prompt.logs) { + if (log.kind === 'toolCall') { + steps.push({ + kind: 'toolCall', + id: log.id, + line: 0, + toolName: log.tool, + args: JSON.parse(log.args), + edits: log.edits, + results: log.response + }); + } else if (log.kind === 'request') { + steps.push({ + kind: 'request', + id: log.id, + line: 0, + prompt: log.messages, + result: log.response.message + }); + } + } + + return steps; +} + +/** + * Assigns line numbers to steps based on their location in the content + */ +function assignLineNumbers(steps: ChatStep[], content: string): void { + let stepIx = 0; + const lines = content.split('\n'); + + lines.forEach((line, index) => { + if (stepIx < steps.length) { + const step = steps[stepIx]; + if (step.kind === 'userQuery') { + const match = line.match(`"prompt": "${step.query.trim()}`); + if (match) { + step.line = index + 1; + stepIx++; + } + } else { + const match = line.match(`"id": "${step.id}"`); + if (match) { + step.line = index + 1; + stepIx++; + } + } + } + }); +} diff --git a/src/extension/replay/common/replaySessionManager.ts b/src/extension/replay/common/replaySessionManager.ts new file mode 100644 index 000000000..0fd9b4d7e --- /dev/null +++ b/src/extension/replay/common/replaySessionManager.ts @@ -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 { DeferredPromise } from '../../../util/vs/base/common/async'; +import { ChatStep } from './chatReplayResponses'; +import { parseReplayFromSessionId } from './replayParser'; + +/** + * Manages a single replay session with async iteration support. + * + * The ReplaySession allows both synchronous access to all steps and asynchronous iteration + * where steps are provided one at a time as the user steps through them in debug mode. + * + * Example usage: + * ```typescript + * // In debug mode, iterate steps as they become available: + * const session = sessionManager.getOrCreateSession(sessionId); + * for await (const step of session) { + * console.log('Processing step:', step); + * // In another context, call session.stepNext() to advance + * } + * + * // Or manually control stepping: + * session.stepNext(); // Advances to next step + * const current = session.currentStep; // Get current step + * + * // For non-debug mode, process all steps immediately: + * session.stepToEnd(); + * const allSteps = session.allSteps; + * ``` + */ +export class ReplaySession implements AsyncIterable { + private _allSteps: ChatStep[]; + private _currentIndex = 0; + private _pendingRequests: DeferredPromise[] = []; + + constructor( + readonly sessionId: string, + readonly filePath: string, + allSteps: ChatStep[] + ) { + this._allSteps = allSteps; + } + + /** + * Gets all steps that have been loaded (for backward compatibility) + */ + get allSteps(): ChatStep[] { + return this._allSteps; + } + + /** + * Gets the current step + */ + get currentStep(): ChatStep | undefined { + if (this._currentIndex >= 0 && this._currentIndex < this._allSteps.length) { + return this._allSteps[this._currentIndex]; + } + return undefined; + } + + /** + * Gets the total number of steps + */ + get totalSteps(): number { + return this._allSteps.length; + } + + /** + * Creates an async iterable for the chat steps + */ + async *iterateSteps(): AsyncIterableIterator { + // Yield all already-processed steps + for (let i = 0; i < this._currentIndex; i++) { + yield this._allSteps[i]; + } + + // Yield future steps as they become available + while (this._currentIndex < this._allSteps.length) { + const step = await this.waitForNextStep(); + if (step) { + yield step; + } else { + break; // Session terminated + } + } + } + + /** + * Implements AsyncIterable interface + */ + [Symbol.asyncIterator](): AsyncIterableIterator { + return this.iterateSteps(); + } + + /** + * Waits for the next step to be available + */ + private async waitForNextStep(): Promise { + if (this._currentIndex < this._allSteps.length) { + // Create a deferred promise that will be resolved when stepNext is called + const deferred = new DeferredPromise(); + this._pendingRequests.push(deferred); + return deferred.p; + } + return undefined; + } + + /** + * Advances to the next step (called by debug session) + */ + stepNext(): ChatStep | undefined { + if (this._currentIndex >= this._allSteps.length) { + // Resolve all pending requests with undefined + while (this._pendingRequests.length > 0) { + const deferred = this._pendingRequests.shift(); + deferred?.complete(undefined); + } + return undefined; + } + + const step = this._allSteps[this._currentIndex]; + this._currentIndex++; + + // Resolve the oldest pending request with this step + const deferred = this._pendingRequests.shift(); + if (deferred) { + deferred.complete(step); + } + + return step; + } + + /** + * Advances to a specific step index + */ + stepTo(index: number): void { + while (this._currentIndex < index && this._currentIndex < this._allSteps.length) { + this.stepNext(); + } + } + + /** + * Resets the session to the beginning + */ + reset(): void { + this._currentIndex = 0; + // Cancel all pending requests + while (this._pendingRequests.length > 0) { + const deferred = this._pendingRequests.shift(); + deferred?.complete(undefined); + } + } + + /** + * Processes all remaining steps immediately (for non-debug mode) + */ + stepToEnd(): void { + this.stepTo(this._allSteps.length); + } + + dispose(): void { + // Cancel all pending requests + while (this._pendingRequests.length > 0) { + const deferred = this._pendingRequests.shift(); + deferred?.complete(undefined); + } + } +} + +/** + * Manages all replay sessions + */ +export class ReplaySessionManager { + private _sessions = new Map(); + + /** + * Gets or creates a replay session + */ + getOrCreateSession(sessionId: string): ReplaySession { + let session = this._sessions.get(sessionId); + if (!session) { + // Parse the session ID to get the replay data + const replayData = parseReplayFromSessionId(sessionId); + session = new ReplaySession(sessionId, replayData.filePath, replayData.chatSteps); + this._sessions.set(sessionId, session); + } + return session; + } + + /** + * Creates a new session (disposing any existing session with the same ID) + */ + CreateNewSession(sessionId: string): ReplaySession { + let session = this._sessions.get(sessionId); + if (session) { + session.dispose(); + this._sessions.delete(sessionId); + } + + // Parse the session ID to get the replay data + const replayData = parseReplayFromSessionId(sessionId); + session = new ReplaySession(sessionId, replayData.filePath, replayData.chatSteps); + this._sessions.set(sessionId, session); + return session; + } + + /** + * Gets an existing session + */ + getSession(sessionId: string): ReplaySession | undefined { + return this._sessions.get(sessionId); + } + + /** + * Checks if a session exists + */ + hasSession(sessionId: string): boolean { + return this._sessions.has(sessionId); + } + + /** + * Removes a session + */ + removeSession(sessionId: string): void { + const session = this._sessions.get(sessionId); + if (session) { + session.dispose(); + this._sessions.delete(sessionId); + } + } + + /** + * Disposes all sessions + */ + dispose(): void { + for (const session of this._sessions.values()) { + session.dispose(); + } + this._sessions.clear(); + } +} diff --git a/src/extension/replay/vscode-node/chatReplayContrib.ts b/src/extension/replay/vscode-node/chatReplayContrib.ts index d0f2fb061..16021bb09 100644 --- a/src/extension/replay/vscode-node/chatReplayContrib.ts +++ b/src/extension/replay/vscode-node/chatReplayContrib.ts @@ -2,23 +2,42 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { CancellationToken, commands, debug, DebugAdapterDescriptor, DebugAdapterDescriptorFactory, DebugAdapterInlineImplementation, DebugConfiguration, DebugConfigurationProvider, DebugSession, ProviderResult, window, WorkspaceFolder } from 'vscode'; +import { CancellationToken, chat, commands, debug, DebugAdapterDescriptor, DebugAdapterDescriptorFactory, DebugAdapterInlineImplementation, DebugConfiguration, DebugConfigurationProvider, DebugSession, ProviderResult, window, WorkspaceFolder } from 'vscode'; import { IRequestLogger } from '../../../platform/requestLogger/node/requestLogger'; +import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService'; import { Disposable } from '../../../util/vs/base/common/lifecycle'; import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; +import { ChatReplaySessionProvider } from './chatReplaySessionProvider'; import { ChatReplayDebugSession } from './replayDebugSession'; export class ChatReplayContribution extends Disposable { + private _sessionProvider: ChatReplaySessionProvider; + constructor( @IInstantiationService private readonly _instantiationService: IInstantiationService, + @IWorkspaceService private readonly _workspaceService: IWorkspaceService ) { super(); + this._sessionProvider = this._register(new ChatReplaySessionProvider(this._workspaceService)); + + // Register the chat session providers (new approach) + this._register(chat.registerChatSessionItemProvider('chat-replay', this._sessionProvider)); + const chatParticipant = chat.createChatParticipant('chat-replay', async (request, context, response, token) => { + // Chat replay participant - replays are read-only, so this handler is mostly a stub + // The actual replay content is provided via the ChatSessionContentProvider + return {}; + }); + this._register(chat.registerChatSessionContentProvider('chat-replay', this._sessionProvider, chatParticipant)); + + // Register debug providers (original approach - still useful for detailed debugging) const provider = new ChatReplayConfigProvider(); this._register(debug.registerDebugConfigurationProvider('vscode-chat-replay', provider)); - const factory = new InlineDebugAdapterFactory(); + const factory = new InlineDebugAdapterFactory(this._sessionProvider); this._register(debug.registerDebugAdapterDescriptorFactory('vscode-chat-replay', factory)); + + // Register commands this.registerStartReplayCommand(); this.registerEnableWorkspaceEditTracingCommand(); this.registerDisableWorkspaceEditTracingCommand(); @@ -64,9 +83,10 @@ export class ChatReplayContribution extends Disposable { } class InlineDebugAdapterFactory implements DebugAdapterDescriptorFactory { + constructor(private readonly sessionProvider: ChatReplaySessionProvider) { } createDebugAdapterDescriptor(session: DebugSession): ProviderResult { - return new DebugAdapterInlineImplementation(new ChatReplayDebugSession(session.workspaceFolder)); + return new DebugAdapterInlineImplementation(new ChatReplayDebugSession(session.workspaceFolder, this.sessionProvider)); } } diff --git a/src/extension/replay/vscode-node/chatReplaySessionProvider.ts b/src/extension/replay/vscode-node/chatReplaySessionProvider.ts new file mode 100644 index 000000000..fabec2b91 --- /dev/null +++ b/src/extension/replay/vscode-node/chatReplaySessionProvider.ts @@ -0,0 +1,139 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken, ChatRequest, ChatRequestTurn2, ChatResponseStream, ChatSession, ChatSessionContentProvider, ChatSessionItem, ChatSessionItemProvider, ChatToolInvocationPart, Event, EventEmitter, ProviderResult } from 'vscode'; +import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService'; +import { Disposable } from '../../../util/vs/base/common/lifecycle'; +import { ChatStep } from '../common/chatReplayResponses'; +import { ReplaySessionManager } from '../common/replaySessionManager'; +import { EditHelper } from './editHelper'; + +export class ChatReplaySessionProvider extends Disposable implements ChatSessionContentProvider, ChatSessionItemProvider { + private _onDidChangeChatSessionItems = this._register(new EventEmitter()); + readonly onDidChangeChatSessionItems: Event = this._onDidChangeChatSessionItems.event; + + private readonly editHelper: EditHelper; + private readonly _sessionManager: ReplaySessionManager; + + constructor(IWorkspaceService: IWorkspaceService, sessionManager?: ReplaySessionManager) { + super(); + this.editHelper = new EditHelper(IWorkspaceService); + this._sessionManager = sessionManager ?? this._register(new ReplaySessionManager()); + } + + onDidCommitChatSessionItem: Event<{ original: ChatSessionItem; modified: ChatSessionItem }> = this._register(new EventEmitter<{ original: ChatSessionItem; modified: ChatSessionItem }>()).event; + + provideNewChatSessionItem?(options: { readonly request: ChatRequest; metadata?: unknown }, token: CancellationToken): ProviderResult { + throw new Error('Method not implemented.'); + } + + provideChatSessionItems(token: CancellationToken): ProviderResult { + return []; + } + + // ChatSessionContentProvider implementation + provideChatSessionContent(sessionId: string, token: CancellationToken): ChatSession { + return this.startReplayDebugging(sessionId, token); + } + + initializeReplaySession(sessionId: string) { + const session = this._sessionManager.CreateNewSession(sessionId); + return session.allSteps; + } + + currentStep(sessionId: string): ChatStep | undefined { + const session = this._sessionManager.getSession(sessionId); + return session?.currentStep; + } + + getSession(sessionId: string) { + return this._sessionManager.getSession(sessionId); + } + + // private convertStepsToHistory(chatSteps: ChatStep[], debugMode: boolean = false): ReadonlyArray { + // const history: (ChatRequestTurn | ChatResponseTurn2)[] = []; + // let currentResponseSteps: ChatStep[] = []; + + // for (const step of chatSteps) { + // if (step.kind === 'userQuery') { + // // In debug mode, only include completed response turns + // if (!debugMode && currentResponseSteps.length > 0) { + // history.push(this.createResponseTurn(currentResponseSteps)); + // currentResponseSteps = []; + // } + + // // Always create request turn for user query + // history.push(this.createRequestTurn(step)); + // } else if (step.kind === 'request' || step.kind === 'toolCall') { + // // In debug mode, don't add response steps to history - they'll be streamed + // if (!debugMode) { + // currentResponseSteps.push(step); + // } + // } + // } + + // // Complete any remaining response turn (only in non-debug mode) + // if (!debugMode && currentResponseSteps.length > 0) { + // history.push(this.createResponseTurn(currentResponseSteps)); + // } + + // return history; + // } + + private createRequestTurn(step: ChatStep & { kind: 'userQuery' }): ChatRequestTurn2 { + return new ChatRequestTurn2(step.query, undefined, [], 'copilot', [], undefined); + } + + + // Method to start debugging/stepping through a replay session + public startReplayDebugging(sessionId: string, token: CancellationToken): ChatSession { + // Handle debug session ID prefix + const actualSessionId = sessionId.startsWith('debug:') ? sessionId.substring(6) : sessionId; + + // Ensure session is loaded + const session = this._sessionManager.getSession(actualSessionId); + const query = session?.stepNext(); + const history = query && query.kind === 'userQuery' ? [this.createRequestTurn(query)] : []; + + return { + history, + activeResponseCallback: (stream, token) => this.handleActiveResponse(sessionId, stream, token), + requestHandler: undefined // This will be read-only for replay + }; + } + + private async handleActiveResponse(sessionId: string, stream: ChatResponseStream, token: CancellationToken): Promise { + const replaySession = this._sessionManager.getSession(sessionId); + if (!replaySession) { + return; + } + + for await (const step of replaySession.iterateSteps()) { + if (token.isCancellationRequested) { + break; + } + + if (step.kind === 'request') { + const result = Array.isArray(step.result) ? step.result.join('') : (step.result || ''); + stream.markdown(result); + } else if (step.kind === 'toolCall') { + const toolPart = new ChatToolInvocationPart(step.toolName, step.id); + toolPart.isComplete = true; + toolPart.isError = false; + toolPart.isConfirmed = true; + + for (const edit of step.edits) { + await this.editHelper.makeEdit(edit, stream); + } + + stream.push(toolPart); + } + } + } + + fireSessionsChanged(): void { + this._onDidChangeChatSessionItems.fire(); + } +} \ No newline at end of file diff --git a/src/extension/replay/vscode-node/editHelper.ts b/src/extension/replay/vscode-node/editHelper.ts new file mode 100644 index 000000000..4eb28df7c --- /dev/null +++ b/src/extension/replay/vscode-node/editHelper.ts @@ -0,0 +1,70 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ChatResponseStream, Range, Uri, WorkspaceEdit } from 'vscode'; +import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService'; +import { FileEdits, Replacement } from '../common/chatReplayResponses'; + +export class EditHelper { + constructor(private readonly workspaceService: IWorkspaceService) { } + + public async makeEdit(edits: FileEdits, stream: ChatResponseStream) { + let uri: Uri; + if (!edits.path.startsWith('/') && !edits.path.match(/^[a-zA-Z]:/)) { + // Relative path - join with first workspace folder + const workspaceFolders = this.workspaceService.getWorkspaceFolders(); + if (workspaceFolders.length > 0) { + uri = Uri.joinPath(workspaceFolders[0], edits.path); + } else { + throw new Error('No workspace folder available to resolve relative path: ' + edits.path); + } + } else { + // Absolute path + uri = Uri.file(edits.path); + } + + await this.ensureFileExists(uri); + + stream.markdown('\n```\n'); + stream.codeblockUri(uri, true); + await Promise.all(edits.edits.replacements.map(r => this.performReplacement(uri, r, stream))); + stream.textEdit(uri, true); + stream.markdown('\n' + '```\n'); + } + + private async ensureFileExists(uri: Uri): Promise { + try { + await this.workspaceService.fs.stat(uri); + return; // Exists + } catch { + // Create parent directory and empty file + const parent = Uri.joinPath(uri, '..'); + await this.workspaceService.fs.createDirectory(parent); + await this.workspaceService.fs.writeFile(uri, new Uint8Array()); + } + } + + private async performReplacement(uri: Uri, replacement: Replacement, stream: ChatResponseStream) { + const doc = await this.workspaceService.openTextDocument(uri); + const workspaceEdit = new WorkspaceEdit(); + const range = new Range( + doc.positionAt(replacement.replaceRange.start), + doc.positionAt(replacement.replaceRange.endExclusive) + ); + + workspaceEdit.replace(uri, range, replacement.newText); + + for (const textEdit of workspaceEdit.entries()) { + const edits = Array.isArray(textEdit[1]) ? textEdit[1] : [textEdit[1]]; + for (const textEdit of edits) { + try { + stream.textEdit(uri, textEdit); + } catch (error) { + stream.markdown(`Failed to apply text edit: ${error.message}`); + } + } + } + } +} \ No newline at end of file diff --git a/src/extension/replay/vscode-node/replayDebugSession.ts b/src/extension/replay/vscode-node/replayDebugSession.ts index 81b9377f5..e9d735d7b 100644 --- a/src/extension/replay/vscode-node/replayDebugSession.ts +++ b/src/extension/replay/vscode-node/replayDebugSession.ts @@ -14,10 +14,10 @@ import { Thread } from '@vscode/debugadapter'; import type { DebugProtocol } from '@vscode/debugprotocol'; -import * as fs from 'node:fs'; import * as path from 'node:path'; -import { commands, type WorkspaceFolder } from 'vscode'; -import { ChatReplayResponses, ChatStep } from '../common/chatReplayResponses'; +import { window, type WorkspaceFolder } from 'vscode'; +import { createSessionIdFromFilePath } from '../common/replayParser'; +import { ChatReplaySessionProvider } from './chatReplaySessionProvider'; export class ChatReplayDebugSession extends LoggingDebugSession { @@ -25,15 +25,15 @@ export class ChatReplayDebugSession extends LoggingDebugSession { private _workspaceFolder: WorkspaceFolder | undefined; private _program: string = ''; - private _chatSteps: ChatStep[] = []; - private _currentIndex = -1; private _stopOnEntry = true; private _variableHandles = new Handles(); - private _replay = ChatReplayResponses.getInstance(); + private _sessionProvider: ChatReplaySessionProvider; + private _sessionId: string = ''; - constructor(workspaceFolder: WorkspaceFolder | undefined) { + constructor(workspaceFolder: WorkspaceFolder | undefined, sessionProvider: ChatReplaySessionProvider) { super(); this._workspaceFolder = workspaceFolder; + this._sessionProvider = sessionProvider; // all line/column numbers are 1-based in DAP this.setDebuggerLinesStartAt1(true); this.setDebuggerColumnsStartAt1(true); @@ -63,20 +63,25 @@ export class ChatReplayDebugSession extends LoggingDebugSession { ? programArg : path.join(this._workspaceFolder?.uri.fsPath || process.cwd(), programArg); - const content = fs.readFileSync(this._program, 'utf8'); - this._chatSteps = this.parseReplay(content); + // Generate session ID for communication with chat session provider + this._sessionId = createSessionIdFromFilePath(this._program); + + this._sessionProvider.initializeReplaySession(this._sessionId); + const replaySession = this._sessionProvider.getSession(this._sessionId); + + if (!replaySession) { + throw new Error('Failed to initialize replay session'); + } this.sendResponse(response); - if (this._chatSteps.length === 0) { + if (replaySession.totalSteps === 0) { // Nothing to debug; terminate immediately this.sendEvent(new TerminatedEvent()); return; } - this._currentIndex = 0; - this._replay = ChatReplayResponses.create(() => this.sendEvent(new TerminatedEvent())); - startReplayInChat(); + await window.showChatSession('chat-replay', this._sessionId, {}); if (this._stopOnEntry) { this.sendEvent(new StoppedEvent('entry', ChatReplayDebugSession.THREAD_ID)); @@ -87,7 +92,7 @@ export class ChatReplayDebugSession extends LoggingDebugSession { } protected override disconnectRequest(response: DebugProtocol.DisconnectResponse): void { - this._replay.markDone(); + this._sessionProvider.getSession(this._sessionId)?.dispose(); this.sendResponse(response); this.sendEvent(new TerminatedEvent()); } @@ -143,11 +148,10 @@ export class ChatReplayDebugSession extends LoggingDebugSession { protected override continueRequest(response: DebugProtocol.ContinueResponse, args: DebugProtocol.ContinueArguments): void { const step = this.currentStep(); if (step) { - this.replayNextResponse(step); + this.replayNextResponse(); this.sendResponse(response); } else { // We're done - this._replay.markDone(); this.sendResponse(response); this.sendEvent(new TerminatedEvent()); } @@ -156,21 +160,28 @@ export class ChatReplayDebugSession extends LoggingDebugSession { protected override nextRequest(response: DebugProtocol.NextResponse, args: DebugProtocol.NextArguments): void { const step = this.currentStep(); if (step) { - this.replayNextResponse(step); + this.replayNextResponse(); this.sendResponse(response); } else { - this._replay.markDone(); this.sendResponse(response); this.sendEvent(new TerminatedEvent()); } } - private replayNextResponse(step: ChatStep): void { - this._replay.replayResponse(step); - this._currentIndex++; + private replayNextResponse(): void { + const replaySession = this._sessionProvider.getSession(this._sessionId); + if (!replaySession) { + return; + } + + replaySession.stepNext(); - // Send a stopped event to indicate we are at the next step - this.sendEvent(new StoppedEvent('next', ChatReplayDebugSession.THREAD_ID)); + if (replaySession.currentStep) { + this.sendEvent(new StoppedEvent('next', ChatReplayDebugSession.THREAD_ID)); + } else { + this.sendEvent(new TerminatedEvent()); + replaySession.dispose(); + } } @@ -180,89 +191,7 @@ export class ChatReplayDebugSession extends LoggingDebugSession { this.sendEvent(new StoppedEvent('pause', ChatReplayDebugSession.THREAD_ID)); } - private currentStep(): ChatStep | undefined { - if (this._currentIndex >= 0 && this._currentIndex < this._chatSteps.length) { - return this._chatSteps[this._currentIndex]; - } - - this._currentIndex++; - return undefined; + private currentStep() { + return this._sessionProvider.currentStep(this._sessionId); } - - private parsePrompt(prompt: { [key: string]: any }) { - const steps: ChatStep[] = []; - steps.push({ - kind: 'userQuery', - query: prompt.prompt, - line: 0, - }); - - for (const log of prompt.logs) { - if (log.kind === 'toolCall') { - steps.push({ - kind: 'toolCall', - id: log.id, - line: 0, - toolName: log.tool, - args: JSON.parse(log.args), - edits: log.edits, - results: log.response - }); - } else if (log.kind === 'request') { - steps.push({ - kind: 'request', - id: log.id, - line: 0, - prompt: log.messages, - result: log.response.message - }); - } - } - - return steps; - } - - private parseReplay(content: string): ChatStep[] { - const parsed = JSON.parse(content); - const prompts = (parsed.prompts && Array.isArray(parsed.prompts) ? parsed.prompts : [parsed]) as { [key: string]: any }[]; - if (prompts.filter(p => !p.prompt).length) { - throw new Error('Invalid replay content: expected a prompt object or an array of prompts in the base JSON structure.'); - } - - const steps: ChatStep[] = []; - for (const prompt of prompts) { - steps.push(...this.parsePrompt(prompt)); - } - - let stepIx = 0; - const lines = content.split('\n'); - lines.forEach((line, index) => { - if (stepIx < steps.length) { - const step = steps[stepIx]; - if (step.kind === 'userQuery') { - const match = line.match(`"prompt": "${step.query.trim()}`); - if (match) { - step.line = index + 1; - stepIx++; - } - } else { - const match = line.match(`"id": "${step.id}"`); - if (match) { - step.line = index + 1; - stepIx++; - } - } - - } - }); - return steps; - } -} - -async function startReplayInChat() { - await commands.executeCommand('workbench.panel.chat.view.copilot.focus'); - await commands.executeCommand('type', { - text: `\@chatReplay`, - }); - await commands.executeCommand('workbench.action.chat.submit'); } diff --git a/src/extension/tools/node/allTools.ts b/src/extension/tools/node/allTools.ts index 05c736c7a..e2ab62a0e 100644 --- a/src/extension/tools/node/allTools.ts +++ b/src/extension/tools/node/allTools.ts @@ -38,4 +38,3 @@ import './usagesTool'; import './userPreferencesTool'; import './vscodeAPITool'; import './vscodeCmdTool'; -import './toolReplayTool'; diff --git a/src/extension/tools/node/toolReplayTool.tsx b/src/extension/tools/node/toolReplayTool.tsx deleted file mode 100644 index 2a776999c..000000000 --- a/src/extension/tools/node/toolReplayTool.tsx +++ /dev/null @@ -1,37 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import type { CancellationToken, LanguageModelTool, LanguageModelToolInvocationOptions, LanguageModelToolInvocationPrepareOptions, PreparedToolInvocation, ProviderResult } from 'vscode'; -import { LanguageModelTextPart, LanguageModelToolResult } from '../../../vscodeTypes'; -import { ToolName } from '../common/toolNames'; -import { ToolRegistry } from '../common/toolsRegistry'; -import { ChatReplayResponses } from '../../replay/common/chatReplayResponses'; - -type ToolReplayParams = { - toolCallId: string; - toolName: string; - toolCallArgs: { [key: string]: any }; -} - -export class ToolReplayTool implements LanguageModelTool { - public static readonly toolName = ToolName.ToolReplay; - - invoke(options: LanguageModelToolInvocationOptions, token: CancellationToken) { - const replay = ChatReplayResponses.getInstance(); - const { toolCallId } = options.input; - const toolResults = replay.getToolResult(toolCallId) ?? []; - - return new LanguageModelToolResult(toolResults.map(result => new LanguageModelTextPart(result))); - } - - prepareInvocation(options: LanguageModelToolInvocationPrepareOptions, token: CancellationToken): ProviderResult { - return { - invocationMessage: options.input.toolName - }; - } - -} - -ToolRegistry.registerTool(ToolReplayTool);