From 4233a0b5b0570e4b165feb860fa492bb9b4e069c Mon Sep 17 00:00:00 2001 From: amunger <2019016+amunger@users.noreply.github.com> Date: Fri, 26 Sep 2025 11:14:57 -0700 Subject: [PATCH 1/6] use a chat Session provider to display the chat replay --- package.json | 18 +- .../vscode-node/chatParticipants.ts | 8 - src/extension/intents/node/allIntents.ts | 4 +- .../intents/node/chatReplayIntent.ts | 137 ------- .../replay/common/chatReplayResponses.ts | 2 +- .../replay/vscode-node/chatReplayContrib.ts | 21 +- .../vscode-node/chatReplaySessionProvider.ts | 363 ++++++++++++++++++ .../replay/vscode-node/editHelper.ts | 57 +++ .../replay/vscode-node/replayDebugSession.ts | 42 +- 9 files changed, 474 insertions(+), 178 deletions(-) delete mode 100644 src/extension/intents/node/chatReplayIntent.ts create mode 100644 src/extension/replay/vscode-node/chatReplaySessionProvider.ts create mode 100644 src/extension/replay/vscode-node/editHelper.ts diff --git a/package.json b/package.json index 99c6cd3b1..9142d1576 100644 --- a/package.json +++ b/package.json @@ -1673,15 +1673,6 @@ ] } ] - }, - { - "id": "github.copilot.chatReplay", - "name": "chatReplay", - "fullName": "Chat Replay", - "when": "debugType == 'vscode-chat-replay'", - "locations": [ - "panel" - ] } ], "languageModelChatProviders": [ @@ -3983,6 +3974,13 @@ "supportsFileAttachments": true, "supportsToolAttachments": false } + }, + { + "id": "chat-replay", + "type": "chat-replay", + "name": "replay", + "displayName": "Chat Replay", + "description": "Replay chat sessions from JSON files" } ], "debuggers": [ @@ -4195,4 +4193,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 deleted file mode 100644 index 4ea4d8104..000000000 --- a/src/extension/intents/node/chatReplayIntent.ts +++ /dev/null @@ -1,137 +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 * as l10n from '@vscode/l10n'; -import type * as vscode from 'vscode'; -import { ChatLocation } from '../../../platform/chat/common/commonTypes'; -import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService'; -import { raceCancellation } from '../../../util/vs/base/common/async'; -import { CancellationToken } from '../../../util/vs/base/common/cancellation'; -import { Event } from '../../../util/vs/base/common/event'; -import { Range, Uri, WorkspaceEdit } from '../../../vscodeTypes'; -import { Intent } from '../../common/constants'; -import { Conversation } from '../../prompt/common/conversation'; -import { ChatTelemetryBuilder } from '../../prompt/node/chatParticipantTelemetry'; -import { IDocumentContext } from '../../prompt/node/documentContext'; -import { IIntent, IIntentInvocation, IIntentInvocationContext } from '../../prompt/node/intents'; -import { ChatReplayResponses, ChatStep, FileEdits, Replacement } from '../../replay/common/chatReplayResponses'; -import { ToolName } from '../../tools/common/toolNames'; -import { IToolsService } from '../../tools/common/toolsService'; - -export class ChatReplayIntent implements IIntent { - - static readonly ID: Intent = Intent.ChatReplay; - - readonly id: string = ChatReplayIntent.ID; - - readonly description = l10n.t('Replay a previous conversation'); - - readonly locations = [ChatLocation.Panel]; - - isListedCapability = false; - - constructor( - @IWorkspaceService private readonly workspaceService: IWorkspaceService, - @IToolsService private readonly toolsService: IToolsService - ) { } - - invoke(invocationContext: IIntentInvocationContext): Promise { - // implement handleRequest ourselves so we can skip implementing this. - throw new Error('Method not implemented.'); - } - - async handleRequest(conversation: Conversation, request: vscode.ChatRequest, stream: vscode.ChatResponseStream, token: CancellationToken, documentContext: IDocumentContext | undefined, agentName: string, location: ChatLocation, chatTelemetry: ChatTelemetryBuilder, onPaused: Event): Promise { - const replay = ChatReplayResponses.getInstance(); - let res = await raceCancellation(replay.getResponse(), token); - - while (res && res !== 'finished') { - // Stop processing if cancelled - await raceCancellation(this.processStep(res, replay, stream, request.toolInvocationToken), token); - res = await raceCancellation(replay.getResponse(), token); - } - - if (token.isCancellationRequested) { - replay.cancelReplay(); - } - - return {}; - } - - private async processStep(step: ChatStep, replay: ChatReplayResponses, stream: vscode.ChatResponseStream, toolToken: vscode.ChatParticipantToolToken): Promise { - switch (step.kind) { - case 'userQuery': - stream.markdown(`\n\n---\n\n## User Query:\n\n${step.query}\n\n`); - stream.markdown(`## Response:\n\n---\n`); - break; - case 'request': - stream.markdown(`\n\n${step.result}`); - break; - case 'toolCall': - { - replay.setToolResult(step.id, step.results); - const result = await this.toolsService.invokeTool(ToolName.ToolReplay, - { - toolInvocationToken: toolToken, - input: { - toolCallId: step.id, - toolName: step.toolName, - toolCallArgs: step.args - } - }, CancellationToken.None); - if (result.content.length === 0) { - stream.markdown(l10n.t('No result from tool')); - } - - if (step.edits) { - await Promise.all(step.edits.map(edit => this.makeEdit(edit, stream))); - } - break; - } - } - } - - private async makeEdit(edits: FileEdits, stream: vscode.ChatResponseStream) { - const 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: vscode.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) { - stream.textEdit(uri, textEdit); - } - } - } - -} - diff --git a/src/extension/replay/common/chatReplayResponses.ts b/src/extension/replay/common/chatReplayResponses.ts index 6f6f200c4..ff4e44f07 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; diff --git a/src/extension/replay/vscode-node/chatReplayContrib.ts b/src/extension/replay/vscode-node/chatReplayContrib.ts index d0f2fb061..c1622978d 100644 --- a/src/extension/replay/vscode-node/chatReplayContrib.ts +++ b/src/extension/replay/vscode-node/chatReplayContrib.ts @@ -2,23 +2,37 @@ * 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 { Disposable } from '../../../util/vs/base/common/lifecycle'; import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; +import { ChatReplaySessionProvider } from './chatReplaySessionProvider'; import { ChatReplayDebugSession } from './replayDebugSession'; +import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService'; 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)); + this._register(chat.registerChatSessionContentProvider('chat-replay', this._sessionProvider)); + + // 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 +78,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..38ec556ad --- /dev/null +++ b/src/extension/replay/vscode-node/chatReplaySessionProvider.ts @@ -0,0 +1,363 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { CancellationToken, ChatRequestTurn, ChatRequestTurn2, ChatResponseMarkdownPart, ChatResponseStream, ChatResponseTurn2, ChatSession, ChatSessionContentProvider, ChatSessionItem, ChatSessionItemProvider, ChatToolInvocationPart, Event, EventEmitter, MarkdownString, ProviderResult } from 'vscode'; +import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService'; +import { Disposable } from '../../../util/vs/base/common/lifecycle'; +import { ChatStep } from '../common/chatReplayResponses'; +import { EditHelper } from './editHelper'; + +export class ChatReplaySessionProvider extends Disposable implements ChatSessionItemProvider, ChatSessionContentProvider { + private _onDidChangeChatSessionItems = this._register(new EventEmitter()); + readonly onDidChangeChatSessionItems: Event = this._onDidChangeChatSessionItems.event; + + private readonly editHelper: EditHelper; + + private _sessions = new Map(); + private _activeReplays = new Map(); + private _activeStreams = new Map(); + + constructor(IWorkspaceService: IWorkspaceService) { + super(); + this.editHelper = new EditHelper(IWorkspaceService); + } + + provideChatSessionItems(token: CancellationToken): ProviderResult { + return []; + } + + // ChatSessionContentProvider implementation + provideChatSessionContent(sessionId: string, token: CancellationToken): ChatSession { + // Check if this is a debug session based on session ID prefix + const isDebugSession = sessionId.startsWith('debug:'); + const actualSessionId = isDebugSession ? sessionId.substring(6) : sessionId; // Remove 'debug:' prefix + + if (isDebugSession) { + // For debug sessions, initialize debugging and return debug-mode session + // Pass the ORIGINAL sessionId (with debug: prefix) to maintain consistency + return this.startReplayDebugging(sessionId); + } else { + // For normal sessions, return regular content + return this.provideChatSessionContentInternal(actualSessionId, token, false); + } + } + + // Method to provide chat session content in debug mode + provideChatSessionContentForDebug(sessionId: string, token: CancellationToken): ChatSession { + // Handle debug session ID prefix - use actual session ID for data, debug ID for replay state + const actualSessionId = sessionId.startsWith('debug:') ? sessionId.substring(6) : sessionId; + const debugSessionId = sessionId.startsWith('debug:') ? sessionId : `debug:${sessionId}`; + + return this.provideChatSessionContentInternal(actualSessionId, token, true, debugSessionId); + } + + private provideChatSessionContentInternal(sessionId: string, token: CancellationToken, isDebugMode: boolean, debugSessionId?: string): ChatSession { + let sessionData = this._sessions.get(sessionId); + + if (!sessionData) { + // Parse the replay file + const filePath = this.getFilePathFromSessionId(sessionId); + if (!filePath || !fs.existsSync(filePath)) { + throw new Error(`Replay file not found for session ${sessionId}`); + } + + try { + const content = fs.readFileSync(filePath, 'utf8'); + const chatSteps = this.parseReplay(content); + + sessionData = { + filePath, + chatSteps, + history: [] // Will be populated based on debug mode + }; + this._sessions.set(sessionId, sessionData); + } catch (error) { + throw new Error(`Failed to parse replay file ${filePath}: ${error}`); + } + } + + // Generate history based on debug mode + const history = this.convertStepsToHistory(sessionData.chatSteps, isDebugMode); + sessionData.history = history; + + // In debug mode, check if we have response steps to stream + let hasActiveResponse = false; + if (isDebugMode) { + // Use debugSessionId for replay state lookup, fallback to sessionId + const replayStateId = debugSessionId || sessionId; + const replayState = this._activeReplays.get(replayStateId); + hasActiveResponse = replayState !== undefined && replayState.currentStepIndex < replayState.responseSteps.length; + } + + return { + history: history, + // Provide active response callback when debugging + activeResponseCallback: hasActiveResponse + ? (stream, token) => this.handleActiveResponse(debugSessionId || sessionId, stream, token) + : undefined, + requestHandler: undefined // This will be read-only for replay + }; + } + + private 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; + } + } + + 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); + } + + private createResponseTurn(responseSteps: ChatStep[]): ChatResponseTurn2 { + const parts: ChatResponseMarkdownPart[] = responseSteps.map(step => { + let content = ''; + if (step.kind === 'request') { + content = Array.isArray(step.result) ? step.result.join('') : (step.result || ''); + } else if (step.kind === 'toolCall') { + content = `**Tool Call: ${step.toolName}**\n\nArguments:\n\`\`\`json\n${JSON.stringify(step.args, null, 2)}\n\`\`\`\n\nResults:\n${step.results.join('\n')}`; + } + return { + value: new MarkdownString(content), + vulnerabilities: [] + }; + }); + + return new ChatResponseTurn2( + parts, + {}, // result + 'copilot' // participant + ); + } + + private async handleActiveResponse(sessionId: string, stream: ChatResponseStream, token: CancellationToken): Promise { + const replayState = this._activeReplays.get(sessionId); + if (!replayState) { + return; + } + + // Check if this is a debug session that should wait for stepping + const isDebugSession = sessionId.startsWith('debug:'); + + if (isDebugSession) { + // Store stream reference for debug stepping + this._activeStreams.set(sessionId, stream); + + // In debug mode, only stream the current step if we're at the beginning + // Further steps will be streamed via stepNext() calls + if (replayState.currentStepIndex === 0 && replayState.responseSteps.length > 0) { + await this.streamCurrentStep(sessionId, stream); + replayState.currentStepIndex++; + } + } else { + // In normal mode, stream all steps automatically + while (replayState.currentStepIndex < replayState.responseSteps.length && !token.isCancellationRequested) { + const step = replayState.responseSteps[replayState.currentStepIndex]; + + 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; + + stream.push(toolPart); + } + + replayState.currentStepIndex++; + + // Add a delay between steps for better UX + if (replayState.currentStepIndex < replayState.responseSteps.length) { + await new Promise(resolve => setTimeout(resolve, 1000)); + } + } + // Clean up stream reference for normal mode + this._activeStreams.delete(sessionId); + } + } + + // Method to start debugging/stepping through a replay session + public startReplayDebugging(sessionId: string): ChatSession { + // Handle debug session ID prefix + const actualSessionId = sessionId.startsWith('debug:') ? sessionId.substring(6) : sessionId; + const debugSessionId = sessionId.startsWith('debug:') ? sessionId : `debug:${sessionId}`; + + const sessionData = this._sessions.get(actualSessionId); + if (!sessionData) { + // If session data doesn't exist, we need to parse it first + const dummyToken = { isCancellationRequested: false, onCancellationRequested: () => ({ dispose: () => { } }) } as CancellationToken; + this.provideChatSessionContentInternal(actualSessionId, dummyToken, false); + const updatedSessionData = this._sessions.get(actualSessionId); + if (!updatedSessionData) { + throw new Error(`Failed to load session data for ${actualSessionId}`); + } + } + + const finalSessionData = this._sessions.get(actualSessionId)!; + + // Find all response steps (toolCall and request) for the entire session + const responseSteps: ChatStep[] = []; + for (const step of finalSessionData.chatSteps) { + if (step.kind === 'request' || step.kind === 'toolCall') { + responseSteps.push(step); + } + } + + // Store debug state using the DEBUG session ID (with prefix) + this._activeReplays.set(debugSessionId, { + responseSteps, + currentStepIndex: 0 + }); + + // Return the chat session with debug mode enabled + const dummyToken = { isCancellationRequested: false, onCancellationRequested: () => ({ dispose: () => { } }) } as CancellationToken; + return this.provideChatSessionContentForDebug(debugSessionId, dummyToken); + } + + // Method to step to next item (called by debug session) + public async stepNext(sessionId: string) { + const replayState = this._activeReplays.get(sessionId); + const stream = this._activeStreams.get(sessionId); + + if (replayState && stream && replayState.currentStepIndex < replayState.responseSteps.length) { + // Stream the current step + await this.streamCurrentStep(sessionId, stream); + // Advance to next step + replayState.currentStepIndex++; + + // Clean up if we've reached the end + if (replayState.currentStepIndex >= replayState.responseSteps.length) { + this._activeStreams.delete(sessionId); + } + } + } + + private async streamCurrentStep(sessionId: string, stream: ChatResponseStream) { + const replayState = this._activeReplays.get(sessionId); + if (!replayState || replayState.currentStepIndex >= replayState.responseSteps.length) { + return; + } + + const step = replayState.responseSteps[replayState.currentStepIndex]; + + 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; + + stream.push(toolPart); + if (step.edits && step.edits.length > 0) { + await Promise.all(step.edits.map(edit => this.editHelper.makeEdit(edit, stream))); + } + // const toolContent = `**Tool Call: ${step.toolName}**\n\nArguments:\n\`\`\`json\n${JSON.stringify(step.args, null, 2)}\n\`\`\`\n\nResults:\n${step.results.join('\n')}`; + // stream.markdown(toolContent); + } + } + + 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)); + } + + return steps; + } + + private 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; + } + + fireSessionsChanged(): void { + this._onDidChangeChatSessionItems.fire(); + } +} + +interface ChatReplaySessionData { + filePath: string; + chatSteps: ChatStep[]; + history: ReadonlyArray; +} + +interface ChatReplayState { + responseSteps: ChatStep[]; + currentStepIndex: number; +} \ 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..e87268cff --- /dev/null +++ b/src/extension/replay/vscode-node/editHelper.ts @@ -0,0 +1,57 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Uri, WorkspaceEdit, Range, ChatResponseStream } 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) { + const 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..50c67d0ce 100644 --- a/src/extension/replay/vscode-node/replayDebugSession.ts +++ b/src/extension/replay/vscode-node/replayDebugSession.ts @@ -16,8 +16,9 @@ import { 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 { ChatStep } from '../common/chatReplayResponses'; +import { ChatReplaySessionProvider } from './chatReplaySessionProvider'; export class ChatReplayDebugSession extends LoggingDebugSession { @@ -29,11 +30,13 @@ export class ChatReplayDebugSession extends LoggingDebugSession { private _currentIndex = -1; private _stopOnEntry = true; private _variableHandles = new Handles(); - private _replay = ChatReplayResponses.getInstance(); + private _sessionProvider: ChatReplaySessionProvider | undefined; + private _debugSessionId: 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); @@ -75,8 +78,12 @@ export class ChatReplayDebugSession extends LoggingDebugSession { } this._currentIndex = 0; - this._replay = ChatReplayResponses.create(() => this.sendEvent(new TerminatedEvent())); - startReplayInChat(); + + // Generate debug session ID for communication with chat session provider + const baseSessionId = Buffer.from(this._program).toString('base64'); + this._debugSessionId = `debug:${baseSessionId}`; + + await startReplayInChatSession(this._program); if (this._stopOnEntry) { this.sendEvent(new StoppedEvent('entry', ChatReplayDebugSession.THREAD_ID)); @@ -87,7 +94,7 @@ export class ChatReplayDebugSession extends LoggingDebugSession { } protected override disconnectRequest(response: DebugProtocol.DisconnectResponse): void { - this._replay.markDone(); + // Clean up any debug session state this.sendResponse(response); this.sendEvent(new TerminatedEvent()); } @@ -147,7 +154,6 @@ export class ChatReplayDebugSession extends LoggingDebugSession { this.sendResponse(response); } else { // We're done - this._replay.markDone(); this.sendResponse(response); this.sendEvent(new TerminatedEvent()); } @@ -159,14 +165,17 @@ export class ChatReplayDebugSession extends LoggingDebugSession { this.replayNextResponse(step); this.sendResponse(response); } else { - this._replay.markDone(); this.sendResponse(response); this.sendEvent(new TerminatedEvent()); } } private replayNextResponse(step: ChatStep): void { - this._replay.replayResponse(step); + // Use the chat session provider to step through the replay + if (this._sessionProvider) { + this._sessionProvider.stepNext(this._debugSessionId); + } + this._currentIndex++; // Send a stopped event to indicate we are at the next step @@ -259,10 +268,11 @@ export class ChatReplayDebugSession extends LoggingDebugSession { } } -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'); +async function startReplayInChatSession(programPath: string) { + // Generate session ID from file path with debug prefix + const baseSessionId = Buffer.from(programPath).toString('base64'); + const debugSessionId = `debug:${baseSessionId}`; + + // Open the chat session in the editor with debug session ID + await window.showChatSession('chat-replay', debugSessionId, {}); } From 29d11ec53e3dc0643b2abf5dea5b266883451526 Mon Sep 17 00:00:00 2001 From: amunger <2019016+amunger@users.noreply.github.com> Date: Fri, 26 Sep 2025 11:21:41 -0700 Subject: [PATCH 2/6] formatting --- src/extension/replay/vscode-node/editHelper.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/extension/replay/vscode-node/editHelper.ts b/src/extension/replay/vscode-node/editHelper.ts index e87268cff..290c5ce7a 100644 --- a/src/extension/replay/vscode-node/editHelper.ts +++ b/src/extension/replay/vscode-node/editHelper.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Uri, WorkspaceEdit, Range, ChatResponseStream } from 'vscode'; +import { ChatResponseStream, Range, Uri, WorkspaceEdit } from 'vscode'; import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService'; import { FileEdits, Replacement } from '../common/chatReplayResponses'; From ae29c9b1b52a3e4685d8186381da3494dc618ea8 Mon Sep 17 00:00:00 2001 From: amunger <2019016+amunger@users.noreply.github.com> Date: Thu, 2 Oct 2025 16:08:44 -0700 Subject: [PATCH 3/6] focus on debugging, share the replaySession object --- .../replay/common/chatReplayResponses.ts | 9 - src/extension/replay/common/replayParser.ts | 159 ++++++++++ .../replay/common/replaySessionManager.ts | 244 +++++++++++++++ .../vscode-node/chatReplaySessionProvider.ts | 294 +++--------------- .../replay/vscode-node/replayDebugSession.ts | 133 ++------ src/extension/tools/node/toolReplayTool.tsx | 37 --- 6 files changed, 469 insertions(+), 407 deletions(-) create mode 100644 src/extension/replay/common/replayParser.ts create mode 100644 src/extension/replay/common/replaySessionManager.ts delete mode 100644 src/extension/tools/node/toolReplayTool.tsx diff --git a/src/extension/replay/common/chatReplayResponses.ts b/src/extension/replay/common/chatReplayResponses.ts index ff4e44f07..b5f51fd19 100644 --- a/src/extension/replay/common/chatReplayResponses.ts +++ b/src/extension/replay/common/chatReplayResponses.ts @@ -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/chatReplaySessionProvider.ts b/src/extension/replay/vscode-node/chatReplaySessionProvider.ts index 38ec556ad..bef6db231 100644 --- a/src/extension/replay/vscode-node/chatReplaySessionProvider.ts +++ b/src/extension/replay/vscode-node/chatReplaySessionProvider.ts @@ -3,26 +3,24 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as fs from 'node:fs'; import { CancellationToken, ChatRequestTurn, ChatRequestTurn2, ChatResponseMarkdownPart, ChatResponseStream, ChatResponseTurn2, ChatSession, ChatSessionContentProvider, ChatSessionItem, ChatSessionItemProvider, ChatToolInvocationPart, Event, EventEmitter, MarkdownString, 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 ChatSessionItemProvider, ChatSessionContentProvider { +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; - private _sessions = new Map(); - private _activeReplays = new Map(); - private _activeStreams = new Map(); - - constructor(IWorkspaceService: IWorkspaceService) { + constructor(IWorkspaceService: IWorkspaceService, sessionManager?: ReplaySessionManager) { super(); this.editHelper = new EditHelper(IWorkspaceService); + this._sessionManager = sessionManager ?? this._register(new ReplaySessionManager()); } provideChatSessionItems(token: CancellationToken): ProviderResult { @@ -31,85 +29,21 @@ export class ChatReplaySessionProvider extends Disposable implements ChatSession // ChatSessionContentProvider implementation provideChatSessionContent(sessionId: string, token: CancellationToken): ChatSession { - // Check if this is a debug session based on session ID prefix - const isDebugSession = sessionId.startsWith('debug:'); - const actualSessionId = isDebugSession ? sessionId.substring(6) : sessionId; // Remove 'debug:' prefix - - if (isDebugSession) { - // For debug sessions, initialize debugging and return debug-mode session - // Pass the ORIGINAL sessionId (with debug: prefix) to maintain consistency - return this.startReplayDebugging(sessionId); - } else { - // For normal sessions, return regular content - return this.provideChatSessionContentInternal(actualSessionId, token, false); - } + return this.startReplayDebugging(sessionId, token); } - // Method to provide chat session content in debug mode - provideChatSessionContentForDebug(sessionId: string, token: CancellationToken): ChatSession { - // Handle debug session ID prefix - use actual session ID for data, debug ID for replay state - const actualSessionId = sessionId.startsWith('debug:') ? sessionId.substring(6) : sessionId; - const debugSessionId = sessionId.startsWith('debug:') ? sessionId : `debug:${sessionId}`; - - return this.provideChatSessionContentInternal(actualSessionId, token, true, debugSessionId); + initializeReplaySession(sessionId: string) { + const session = this._sessionManager.CreateNewSession(sessionId); + return session.allSteps; } - private provideChatSessionContentInternal(sessionId: string, token: CancellationToken, isDebugMode: boolean, debugSessionId?: string): ChatSession { - let sessionData = this._sessions.get(sessionId); - - if (!sessionData) { - // Parse the replay file - const filePath = this.getFilePathFromSessionId(sessionId); - if (!filePath || !fs.existsSync(filePath)) { - throw new Error(`Replay file not found for session ${sessionId}`); - } - - try { - const content = fs.readFileSync(filePath, 'utf8'); - const chatSteps = this.parseReplay(content); - - sessionData = { - filePath, - chatSteps, - history: [] // Will be populated based on debug mode - }; - this._sessions.set(sessionId, sessionData); - } catch (error) { - throw new Error(`Failed to parse replay file ${filePath}: ${error}`); - } - } - - // Generate history based on debug mode - const history = this.convertStepsToHistory(sessionData.chatSteps, isDebugMode); - sessionData.history = history; - - // In debug mode, check if we have response steps to stream - let hasActiveResponse = false; - if (isDebugMode) { - // Use debugSessionId for replay state lookup, fallback to sessionId - const replayStateId = debugSessionId || sessionId; - const replayState = this._activeReplays.get(replayStateId); - hasActiveResponse = replayState !== undefined && replayState.currentStepIndex < replayState.responseSteps.length; - } - - return { - history: history, - // Provide active response callback when debugging - activeResponseCallback: hasActiveResponse - ? (stream, token) => this.handleActiveResponse(debugSessionId || sessionId, stream, token) - : undefined, - requestHandler: undefined // This will be read-only for replay - }; + currentStep(sessionId: string): ChatStep | undefined { + const session = this._sessionManager.getSession(sessionId); + return session?.currentStep; } - private 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; - } + getSession(sessionId: string) { + return this._sessionManager.getSession(sessionId); } private convertStepsToHistory(chatSteps: ChatStep[], debugMode: boolean = false): ReadonlyArray { @@ -167,197 +101,53 @@ export class ChatReplaySessionProvider extends Disposable implements ChatSession ); } - private async handleActiveResponse(sessionId: string, stream: ChatResponseStream, token: CancellationToken): Promise { - const replayState = this._activeReplays.get(sessionId); - if (!replayState) { - return; - } - - // Check if this is a debug session that should wait for stepping - const isDebugSession = sessionId.startsWith('debug:'); - - if (isDebugSession) { - // Store stream reference for debug stepping - this._activeStreams.set(sessionId, stream); - - // In debug mode, only stream the current step if we're at the beginning - // Further steps will be streamed via stepNext() calls - if (replayState.currentStepIndex === 0 && replayState.responseSteps.length > 0) { - await this.streamCurrentStep(sessionId, stream); - replayState.currentStepIndex++; - } - } else { - // In normal mode, stream all steps automatically - while (replayState.currentStepIndex < replayState.responseSteps.length && !token.isCancellationRequested) { - const step = replayState.responseSteps[replayState.currentStepIndex]; - - 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; - - stream.push(toolPart); - } - - replayState.currentStepIndex++; - - // Add a delay between steps for better UX - if (replayState.currentStepIndex < replayState.responseSteps.length) { - await new Promise(resolve => setTimeout(resolve, 1000)); - } - } - // Clean up stream reference for normal mode - this._activeStreams.delete(sessionId); - } - } - // Method to start debugging/stepping through a replay session - public startReplayDebugging(sessionId: string): ChatSession { + public startReplayDebugging(sessionId: string, token: CancellationToken): ChatSession { // Handle debug session ID prefix const actualSessionId = sessionId.startsWith('debug:') ? sessionId.substring(6) : sessionId; - const debugSessionId = sessionId.startsWith('debug:') ? sessionId : `debug:${sessionId}`; - const sessionData = this._sessions.get(actualSessionId); - if (!sessionData) { - // If session data doesn't exist, we need to parse it first - const dummyToken = { isCancellationRequested: false, onCancellationRequested: () => ({ dispose: () => { } }) } as CancellationToken; - this.provideChatSessionContentInternal(actualSessionId, dummyToken, false); - const updatedSessionData = this._sessions.get(actualSessionId); - if (!updatedSessionData) { - throw new Error(`Failed to load session data for ${actualSessionId}`); - } - } - - const finalSessionData = this._sessions.get(actualSessionId)!; - - // Find all response steps (toolCall and request) for the entire session - const responseSteps: ChatStep[] = []; - for (const step of finalSessionData.chatSteps) { - if (step.kind === 'request' || step.kind === 'toolCall') { - responseSteps.push(step); - } - } - - // Store debug state using the DEBUG session ID (with prefix) - this._activeReplays.set(debugSessionId, { - responseSteps, - currentStepIndex: 0 - }); - - // Return the chat session with debug mode enabled - const dummyToken = { isCancellationRequested: false, onCancellationRequested: () => ({ dispose: () => { } }) } as CancellationToken; - return this.provideChatSessionContentForDebug(debugSessionId, dummyToken); - } - - // Method to step to next item (called by debug session) - public async stepNext(sessionId: string) { - const replayState = this._activeReplays.get(sessionId); - const stream = this._activeStreams.get(sessionId); - - if (replayState && stream && replayState.currentStepIndex < replayState.responseSteps.length) { - // Stream the current step - await this.streamCurrentStep(sessionId, stream); - // Advance to next step - replayState.currentStepIndex++; + // Ensure session is loaded + const session = this._sessionManager.getSession(actualSessionId); + const query = session?.stepNext(); + const history = query && query.kind === 'userQuery' ? [this.createRequestTurn(query)] : []; - // Clean up if we've reached the end - if (replayState.currentStepIndex >= replayState.responseSteps.length) { - this._activeStreams.delete(sessionId); - } - } + return { + history, + activeResponseCallback: (stream, token) => this.handleActiveResponse(sessionId, stream, token), + requestHandler: undefined // This will be read-only for replay + }; } - private async streamCurrentStep(sessionId: string, stream: ChatResponseStream) { - const replayState = this._activeReplays.get(sessionId); - if (!replayState || replayState.currentStepIndex >= replayState.responseSteps.length) { + private async handleActiveResponse(sessionId: string, stream: ChatResponseStream, token: CancellationToken): Promise { + const replaySession = this._sessionManager.getSession(sessionId); + if (!replaySession) { return; } - const step = replayState.responseSteps[replayState.currentStepIndex]; - - 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; - - stream.push(toolPart); - if (step.edits && step.edits.length > 0) { - await Promise.all(step.edits.map(edit => this.editHelper.makeEdit(edit, stream))); + for await (const step of replaySession.iterateSteps()) { + if (token.isCancellationRequested) { + break; } - // const toolContent = `**Tool Call: ${step.toolName}**\n\nArguments:\n\`\`\`json\n${JSON.stringify(step.args, null, 2)}\n\`\`\`\n\nResults:\n${step.results.join('\n')}`; - // stream.markdown(toolContent); - } - } - - 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)); - } - return steps; - } + 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; - private parsePrompt(prompt: { [key: string]: any }): ChatStep[] { - const steps: ChatStep[] = []; - steps.push({ - kind: 'userQuery', - query: prompt.prompt, - line: 0, - }); + for (const edit of step.edits) { + await this.editHelper.makeEdit(edit, stream); + } - 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 - }); + stream.push(toolPart); } } - - return steps; } fireSessionsChanged(): void { this._onDidChangeChatSessionItems.fire(); } -} - -interface ChatReplaySessionData { - filePath: string; - chatSteps: ChatStep[]; - history: ReadonlyArray; -} - -interface ChatReplayState { - responseSteps: ChatStep[]; - currentStepIndex: number; } \ 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 50c67d0ce..0bcf4375e 100644 --- a/src/extension/replay/vscode-node/replayDebugSession.ts +++ b/src/extension/replay/vscode-node/replayDebugSession.ts @@ -14,10 +14,9 @@ import { Thread } from '@vscode/debugadapter'; import type { DebugProtocol } from '@vscode/debugprotocol'; -import * as fs from 'node:fs'; import * as path from 'node:path'; import { window, type WorkspaceFolder } from 'vscode'; -import { ChatStep } from '../common/chatReplayResponses'; +import { createSessionIdFromFilePath } from '../common/replayParser'; import { ChatReplaySessionProvider } from './chatReplaySessionProvider'; export class ChatReplayDebugSession extends LoggingDebugSession { @@ -26,14 +25,12 @@ 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 _sessionProvider: ChatReplaySessionProvider | undefined; - private _debugSessionId: string = ''; + private _sessionProvider: ChatReplaySessionProvider; + private _sessionId: string = ''; - constructor(workspaceFolder: WorkspaceFolder | undefined, sessionProvider?: ChatReplaySessionProvider) { + constructor(workspaceFolder: WorkspaceFolder | undefined, sessionProvider: ChatReplaySessionProvider) { super(); this._workspaceFolder = workspaceFolder; this._sessionProvider = sessionProvider; @@ -66,24 +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; - - // Generate debug session ID for communication with chat session provider - const baseSessionId = Buffer.from(this._program).toString('base64'); - this._debugSessionId = `debug:${baseSessionId}`; - - await startReplayInChatSession(this._program); + await window.showChatSession('chat-replay', this._sessionId, {}); if (this._stopOnEntry) { this.sendEvent(new StoppedEvent('entry', ChatReplayDebugSession.THREAD_ID)); @@ -150,7 +148,7 @@ 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 @@ -162,7 +160,7 @@ 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.sendResponse(response); @@ -170,13 +168,13 @@ export class ChatReplayDebugSession extends LoggingDebugSession { } } - private replayNextResponse(step: ChatStep): void { - // Use the chat session provider to step through the replay - if (this._sessionProvider) { - this._sessionProvider.stepNext(this._debugSessionId); + private replayNextResponse(): void { + const replaySession = this._sessionProvider.getSession(this._sessionId); + if (!replaySession) { + return; } - this._currentIndex++; + replaySession.stepNext(); // Send a stopped event to indicate we are at the next step this.sendEvent(new StoppedEvent('next', ChatReplayDebugSession.THREAD_ID)); @@ -189,90 +187,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 startReplayInChatSession(programPath: string) { - // Generate session ID from file path with debug prefix - const baseSessionId = Buffer.from(programPath).toString('base64'); - const debugSessionId = `debug:${baseSessionId}`; - - // Open the chat session in the editor with debug session ID - await window.showChatSession('chat-replay', debugSessionId, {}); } 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); From 3d540f8c616294ee055823026dc698a3e1dfe69b Mon Sep 17 00:00:00 2001 From: amunger <2019016+amunger@users.noreply.github.com> Date: Thu, 2 Oct 2025 18:52:27 -0700 Subject: [PATCH 4/6] fix errors --- .../intents/node/chatReplayIntent.ts | 2 +- .../replay/vscode-node/chatReplayContrib.ts | 9 +- .../vscode-node/chatReplaySessionProvider.ts | 86 ++++++++----------- 3 files changed, 44 insertions(+), 53 deletions(-) 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/vscode-node/chatReplayContrib.ts b/src/extension/replay/vscode-node/chatReplayContrib.ts index c1622978d..16021bb09 100644 --- a/src/extension/replay/vscode-node/chatReplayContrib.ts +++ b/src/extension/replay/vscode-node/chatReplayContrib.ts @@ -4,11 +4,11 @@ *--------------------------------------------------------------------------------------------*/ 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'; -import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService'; export class ChatReplayContribution extends Disposable { private _sessionProvider: ChatReplaySessionProvider; @@ -23,7 +23,12 @@ export class ChatReplayContribution extends Disposable { // Register the chat session providers (new approach) this._register(chat.registerChatSessionItemProvider('chat-replay', this._sessionProvider)); - this._register(chat.registerChatSessionContentProvider('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(); diff --git a/src/extension/replay/vscode-node/chatReplaySessionProvider.ts b/src/extension/replay/vscode-node/chatReplaySessionProvider.ts index bef6db231..fabec2b91 100644 --- a/src/extension/replay/vscode-node/chatReplaySessionProvider.ts +++ b/src/extension/replay/vscode-node/chatReplaySessionProvider.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { CancellationToken, ChatRequestTurn, ChatRequestTurn2, ChatResponseMarkdownPart, ChatResponseStream, ChatResponseTurn2, ChatSession, ChatSessionContentProvider, ChatSessionItem, ChatSessionItemProvider, ChatToolInvocationPart, Event, EventEmitter, MarkdownString, ProviderResult } from 'vscode'; +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'; @@ -23,6 +23,12 @@ export class ChatReplaySessionProvider extends Disposable implements ChatSession 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 []; } @@ -46,60 +52,40 @@ export class ChatReplaySessionProvider extends Disposable implements ChatSession 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 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); } - private createResponseTurn(responseSteps: ChatStep[]): ChatResponseTurn2 { - const parts: ChatResponseMarkdownPart[] = responseSteps.map(step => { - let content = ''; - if (step.kind === 'request') { - content = Array.isArray(step.result) ? step.result.join('') : (step.result || ''); - } else if (step.kind === 'toolCall') { - content = `**Tool Call: ${step.toolName}**\n\nArguments:\n\`\`\`json\n${JSON.stringify(step.args, null, 2)}\n\`\`\`\n\nResults:\n${step.results.join('\n')}`; - } - return { - value: new MarkdownString(content), - vulnerabilities: [] - }; - }); - - return new ChatResponseTurn2( - parts, - {}, // result - 'copilot' // participant - ); - } // Method to start debugging/stepping through a replay session public startReplayDebugging(sessionId: string, token: CancellationToken): ChatSession { From 8914e6f72c4eb1300aa90f5daab83cee80f69715 Mon Sep 17 00:00:00 2001 From: amunger <2019016+amunger@users.noreply.github.com> Date: Fri, 3 Oct 2025 10:31:39 -0700 Subject: [PATCH 5/6] end session --- src/extension/replay/vscode-node/replayDebugSession.ts | 10 +++++++--- src/extension/tools/node/allTools.ts | 1 - 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/extension/replay/vscode-node/replayDebugSession.ts b/src/extension/replay/vscode-node/replayDebugSession.ts index 0bcf4375e..e9d735d7b 100644 --- a/src/extension/replay/vscode-node/replayDebugSession.ts +++ b/src/extension/replay/vscode-node/replayDebugSession.ts @@ -92,7 +92,7 @@ export class ChatReplayDebugSession extends LoggingDebugSession { } protected override disconnectRequest(response: DebugProtocol.DisconnectResponse): void { - // Clean up any debug session state + this._sessionProvider.getSession(this._sessionId)?.dispose(); this.sendResponse(response); this.sendEvent(new TerminatedEvent()); } @@ -176,8 +176,12 @@ export class ChatReplayDebugSession extends LoggingDebugSession { 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(); + } } 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'; From ed3dcfe7af5ce99ecb6fab58429e45d85930ab4c Mon Sep 17 00:00:00 2001 From: amunger <2019016+amunger@users.noreply.github.com> Date: Fri, 3 Oct 2025 11:14:34 -0700 Subject: [PATCH 6/6] use absolute path --- src/extension/replay/vscode-node/editHelper.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/extension/replay/vscode-node/editHelper.ts b/src/extension/replay/vscode-node/editHelper.ts index 290c5ce7a..4eb28df7c 100644 --- a/src/extension/replay/vscode-node/editHelper.ts +++ b/src/extension/replay/vscode-node/editHelper.ts @@ -11,7 +11,20 @@ export class EditHelper { constructor(private readonly workspaceService: IWorkspaceService) { } public async makeEdit(edits: FileEdits, stream: ChatResponseStream) { - const uri = Uri.file(edits.path); + 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');