From 99d53945a5fadb8d714574cb8a6e19305fbb0cc7 Mon Sep 17 00:00:00 2001 From: Peng Lyu Date: Mon, 23 Mar 2026 10:49:38 -0700 Subject: [PATCH 01/10] fix #303771 --- .../contrib/imageCarousel/browser/media/imageCarousel.css | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/imageCarousel/browser/media/imageCarousel.css b/src/vs/workbench/contrib/imageCarousel/browser/media/imageCarousel.css index 48194e0238b00..35926b8e7b297 100644 --- a/src/vs/workbench/contrib/imageCarousel/browser/media/imageCarousel.css +++ b/src/vs/workbench/contrib/imageCarousel/browser/media/imageCarousel.css @@ -121,6 +121,10 @@ opacity: 0.8; } +.image-carousel-editor .slideshow-container:hover .nav-arrow:disabled { + opacity: 0.3; +} + .image-carousel-editor .nav-arrow:hover:not(:disabled) { opacity: 1 !important; background: var(--vscode-toolbar-activeBackground); @@ -134,9 +138,7 @@ } .image-carousel-editor .nav-arrow:disabled { - opacity: 0 !important; cursor: default; - pointer-events: none; } .image-carousel-editor .nav-arrow.prev-arrow { From a2e920987e6cf61b827819b48bc2296246db0fe9 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Mon, 23 Mar 2026 21:41:48 -0700 Subject: [PATCH 02/10] Fix loading sessions that were created by a previous remote agent host instance (#304344) * Fix loading sessions that were created by a previous instance of the server Co-authored-by: Copilot * Add handleRestoreSession to agentHostMain side effects Wire up the handleRestoreSession method in the utility process agent host entry point, delegating to AgentService which forwards to AgentSideEffects. This was missing after the interface was updated to require session restore support. * Address Copilot review: wrap backend errors, use Cancelled for interrupted turns - Wrap agent.listSessions() and agent.getSessionMessages() calls in try/catch so raw backend errors become ProtocolErrors instead of leaking stack traces to clients. - Use TurnState.Cancelled instead of TurnState.Complete for interrupted/dangling turns during session restoration. - Update test assertions to match new interrupted turn state. (Written by Copilot) --------- Co-authored-by: Copilot --- .../platform/agentHost/node/agentHostMain.ts | 3 + .../platform/agentHost/node/agentService.ts | 4 + .../agentHost/node/agentSideEffects.ts | 208 +++++++++++++++++- .../agentHost/node/protocolServerHandler.ts | 11 +- .../agentHost/node/sessionStateManager.ts | 30 ++- .../test/node/agentSideEffects.test.ts | 177 ++++++++++++++- .../platform/agentHost/test/node/mockAgent.ts | 44 +++- .../test/node/protocolServerHandler.test.ts | 1 + .../node/protocolWebSocket.integrationTest.ts | 43 ++++ .../test/node/sessionStateManager.test.ts | 39 +++- 10 files changed, 548 insertions(+), 12 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentHostMain.ts b/src/vs/platform/agentHost/node/agentHostMain.ts index 0fee9db5598c9..7df7461682c7d 100644 --- a/src/vs/platform/agentHost/node/agentHostMain.ts +++ b/src/vs/platform/agentHost/node/agentHostMain.ts @@ -173,6 +173,9 @@ async function startWebSocketServer(agentService: AgentService, logService: ILog handleBrowseDirectory(uri) { return agentService.browseDirectory(URI.parse(uri)); }, + async handleRestoreSession(session) { + return agentService.restoreSession(URI.parse(session)); + }, handleFetchContent(uri) { return agentService.fetchContent(URI.parse(uri)); }, diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 91b310ec9db13..e4d16a198b244 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -206,6 +206,10 @@ export class AgentService extends Disposable implements IAgentService { return this._sideEffects.handleBrowseDirectory(uri.toString()); } + async restoreSession(session: URI): Promise { + return this._sideEffects.handleRestoreSession(session.toString()); + } + async fetchContent(uri: URI): Promise { return this._sideEffects.handleFetchContent(uri.toString()); } diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index a26dc5150f67f..bf7ea89e51b2c 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -9,14 +9,22 @@ import { autorun, IObservable } from '../../../base/common/observable.js'; import { URI } from '../../../base/common/uri.js'; import { IFileService } from '../../files/common/files.js'; import { ILogService } from '../../log/common/log.js'; -import { IAgent, IAgentAttachment, IAuthenticateParams, IAuthenticateResult, IResourceMetadata } from '../common/agentService.js'; +import { IAgent, IAgentAttachment, IAgentMessageEvent, IAgentToolCompleteEvent, IAgentToolStartEvent, IAuthenticateParams, IAuthenticateResult, IResourceMetadata } from '../common/agentService.js'; import { ISessionDataService } from '../common/sessionDataService.js'; import { ActionType, ISessionAction } from '../common/state/sessionActions.js'; -import { AhpErrorCodes, AHP_PROVIDER_NOT_FOUND, ContentEncoding, IBrowseDirectoryResult, ICreateSessionParams, IDirectoryEntry, IFetchContentResult, ProtocolError } from '../common/state/sessionProtocol.js'; +import { AhpErrorCodes, AHP_PROVIDER_NOT_FOUND, AHP_SESSION_NOT_FOUND, ContentEncoding, IBrowseDirectoryResult, ICreateSessionParams, IDirectoryEntry, IFetchContentResult, JSON_RPC_INTERNAL_ERROR, ProtocolError } from '../common/state/sessionProtocol.js'; import { + ResponsePartKind, SessionStatus, + ToolCallConfirmationReason, + ToolCallStatus, + TurnState, + type IResponsePart, type ISessionModelInfo, - type ISessionSummary, type URI as ProtocolURI, + type ISessionSummary, + type IToolCallCompletedState, + type ITurn, + type URI as ProtocolURI, } from '../common/state/sessionState.js'; import { mapProgressEventToActions } from './agentEventMapper.js'; import type { IProtocolSideEffectHandler } from './protocolServerHandler.js'; @@ -234,6 +242,200 @@ export class AgentSideEffects extends Disposable implements IProtocolSideEffectH return allSessions; } + /** + * Restores a session from a previous server lifetime into the state + * manager. Fetches the session's message history from the agent backend, + * reconstructs `ITurn[]`, and creates the session in the state manager. + * + * @throws {ProtocolError} if the session URI doesn't match any agent or + * the agent cannot retrieve the session messages. + */ + async handleRestoreSession(session: ProtocolURI): Promise { + // Already in state manager - nothing to do. + if (this._stateManager.getSessionState(session)) { + return; + } + + const agent = this._options.getAgent(session); + if (!agent) { + throw new ProtocolError(AHP_SESSION_NOT_FOUND, `No agent for session: ${session}`); + } + + // Verify the session actually exists on the backend to avoid + // creating phantom sessions for made-up URIs. + let allSessions; + try { + allSessions = await agent.listSessions(); + } catch (err) { + if (err instanceof ProtocolError) { + throw err; + } + const message = err instanceof Error ? err.message : String(err); + throw new ProtocolError(JSON_RPC_INTERNAL_ERROR, `Failed to list sessions for ${session}: ${message}`); + } + const meta = allSessions.find(s => s.session.toString() === session); + if (!meta) { + throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session not found on backend: ${session}`); + } + + const sessionUri = URI.parse(session); + let messages; + try { + messages = await agent.getSessionMessages(sessionUri); + } catch (err) { + if (err instanceof ProtocolError) { + throw err; + } + const message = err instanceof Error ? err.message : String(err); + throw new ProtocolError(JSON_RPC_INTERNAL_ERROR, `Failed to restore session ${session}: ${message}`); + } + const turns = this._buildTurnsFromMessages(messages); + + const summary: ISessionSummary = { + resource: session, + provider: agent.id, + title: meta.summary ?? 'Session', + status: SessionStatus.Idle, + createdAt: meta.startTime, + modifiedAt: meta.modifiedTime, + workingDirectory: meta.workingDirectory, + }; + + this._stateManager.restoreSession(summary, turns); + this._logService.info(`[AgentSideEffects] Restored session ${session} with ${turns.length} turns`); + } + + /** + * Reconstructs completed `ITurn[]` from a sequence of agent session + * messages (user messages, assistant messages, tool starts, tool + * completions). Each user-message starts a new turn; the assistant + * message closes it. + */ + private _buildTurnsFromMessages( + messages: readonly (IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent)[], + ): ITurn[] { + const turns: ITurn[] = []; + let currentTurn: { + id: string; + userMessage: { text: string }; + responseText: string; + responseParts: IResponsePart[]; + toolCalls: IToolCallCompletedState[]; + pendingTools: Map; + } | undefined; + + let turnCounter = 0; + + for (const msg of messages) { + if (msg.type === 'message' && msg.role === 'user') { + // Flush any in-progress turn (e.g. interrupted/cancelled + // turn that never got a closing assistant message). + if (currentTurn) { + turns.push({ + id: currentTurn.id, + userMessage: currentTurn.userMessage, + responseText: currentTurn.responseText, + responseParts: currentTurn.responseParts, + toolCalls: currentTurn.toolCalls, + usage: undefined, + state: TurnState.Cancelled, + }); + } + // Start a new turn + currentTurn = { + id: `restored-${turnCounter++}`, + userMessage: { text: msg.content }, + responseText: '', + responseParts: [], + toolCalls: [], + pendingTools: new Map(), + }; + } else if (msg.type === 'message' && msg.role === 'assistant') { + if (!currentTurn) { + // Orphan assistant message - start an implicit turn + currentTurn = { + id: `restored-${turnCounter++}`, + userMessage: { text: '' }, + responseText: '', + responseParts: [], + toolCalls: [], + pendingTools: new Map(), + }; + } + + if (msg.content) { + // Flush any accumulated text as a response part for + // interleaving with tool calls + currentTurn.responseParts.push({ + kind: ResponsePartKind.Markdown, + content: msg.content, + }); + currentTurn.responseText += msg.content; + } + + // If this assistant message has no tool requests, the turn + // is complete. If it has tool requests, more events follow. + if (!msg.toolRequests || msg.toolRequests.length === 0) { + turns.push({ + id: currentTurn.id, + userMessage: currentTurn.userMessage, + responseText: currentTurn.responseText, + responseParts: currentTurn.responseParts, + toolCalls: currentTurn.toolCalls, + usage: undefined, + state: TurnState.Complete, + }); + currentTurn = undefined; + } + } else if (msg.type === 'tool_start') { + currentTurn?.pendingTools.set(msg.toolCallId, msg); + } else if (msg.type === 'tool_complete') { + if (currentTurn) { + const start = currentTurn.pendingTools.get(msg.toolCallId); + currentTurn.pendingTools.delete(msg.toolCallId); + + const tc: IToolCallCompletedState = { + status: ToolCallStatus.Completed, + toolCallId: msg.toolCallId, + toolName: start?.toolName ?? 'unknown', + displayName: start?.displayName ?? 'Unknown Tool', + invocationMessage: start?.invocationMessage ?? '', + toolInput: start?.toolInput, + success: msg.result.success, + pastTenseMessage: msg.result.pastTenseMessage, + content: msg.result.content, + error: msg.result.error, + confirmed: ToolCallConfirmationReason.NotNeeded, + _meta: start ? { + toolKind: start.toolKind, + language: start.language, + } : undefined, + }; + currentTurn.toolCalls.push(tc); + + // If all tools are complete and there are no more pending, + // the turn may be finalized by the next assistant message. + } + } + } + + // If there's a dangling turn (no final assistant message closed it), + // finalize it as cancelled so we don't lose history. + if (currentTurn) { + turns.push({ + id: currentTurn.id, + userMessage: currentTurn.userMessage, + responseText: currentTurn.responseText, + responseParts: currentTurn.responseParts, + toolCalls: currentTurn.toolCalls, + usage: undefined, + state: TurnState.Cancelled, + }); + } + + return turns; + } + handleGetResourceMetadata(): IResourceMetadata { const resources = this._options.agents.get().flatMap(a => a.getProtectedResources()); return { resources }; diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index 740530c4f2f52..6e71a13fba789 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -291,7 +291,14 @@ export class ProtocolServerHandler extends Disposable { */ private readonly _requestHandlers: RequestHandlerMap = { subscribe: async (client, params) => { - const snapshot = this._stateManager.getSnapshot(params.resource); + let snapshot = this._stateManager.getSnapshot(params.resource); + if (!snapshot) { + // Session may exist on the agent backend but not in the + // current state manager (e.g. from a previous server + // lifetime). Try to restore it. + await this._sideEffectHandler.handleRestoreSession(params.resource); + snapshot = this._stateManager.getSnapshot(params.resource); + } if (!snapshot) { throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Resource not found: ${params.resource}`); } @@ -442,6 +449,8 @@ export interface IProtocolSideEffectHandler { handleCreateSession(command: ICreateSessionParams): Promise; handleDisposeSession(session: URI): void; handleListSessions(): Promise; + /** Restore a session from a previous server lifetime into the state manager. */ + handleRestoreSession(session: URI): Promise; handleGetResourceMetadata(): IResourceMetadata; handleAuthenticate(params: IAuthenticateParams): Promise; handleBrowseDirectory(uri: URI): Promise; diff --git a/src/vs/platform/agentHost/node/sessionStateManager.ts b/src/vs/platform/agentHost/node/sessionStateManager.ts index c9ad9235595b3..531c18dd6bc32 100644 --- a/src/vs/platform/agentHost/node/sessionStateManager.ts +++ b/src/vs/platform/agentHost/node/sessionStateManager.ts @@ -9,7 +9,7 @@ import { ILogService } from '../../log/common/log.js'; import { ActionType, NotificationType, IActionEnvelope, IActionOrigin, INotification, ISessionAction, IRootAction, IStateAction, isRootAction, isSessionAction } from '../common/state/sessionActions.js'; import type { IStateSnapshot } from '../common/state/sessionProtocol.js'; import { rootReducer, sessionReducer } from '../common/state/sessionReducers.js'; -import { createRootState, createSessionState, type IRootState, type ISessionState, type ISessionSummary, type URI, ROOT_STATE_URI } from '../common/state/sessionState.js'; +import { createRootState, createSessionState, SessionLifecycle, type IRootState, type ISessionState, type ISessionSummary, type ITurn, type URI, ROOT_STATE_URI } from '../common/state/sessionState.js'; /** * Server-side state manager for the sessions process protocol. @@ -114,6 +114,34 @@ export class SessionStateManager extends Disposable { return state; } + /** + * Restores a session from a previous server lifetime into the state manager + * with pre-populated turns. The session is created in `ready` lifecycle + * state since it already exists on the backend. + * + * Unlike {@link createSession}, this does NOT emit a `sessionAdded` + * notification because the session is already known to clients via + * `listSessions`. + */ + restoreSession(summary: ISessionSummary, turns: ITurn[]): ISessionState { + const key = summary.resource; + if (this._sessionStates.has(key)) { + this._logService.warn(`[SessionStateManager] Session already exists (restore): ${key}`); + return this._sessionStates.get(key)!; + } + + const state: ISessionState = { + ...createSessionState(summary), + lifecycle: SessionLifecycle.Ready, + turns, + }; + this._sessionStates.set(key, state); + + this._logService.trace(`[SessionStateManager] Restored session: ${key} (${turns.length} turns)`); + + return state; + } + /** * Removes a session from state and emits a sessionRemoved notification. */ diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index 43aff21b078d0..1bffae2299303 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -16,7 +16,7 @@ import { NullLogService } from '../../../log/common/log.js'; import { AgentSession, IAgent } from '../../common/agentService.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; import { ActionType, IActionEnvelope, ISessionAction } from '../../common/state/sessionActions.js'; -import { PermissionKind, SessionStatus } from '../../common/state/sessionState.js'; +import { PermissionKind, ResponsePartKind, SessionLifecycle, SessionStatus, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, type IToolCallCompletedState } from '../../common/state/sessionState.js'; import { AgentSideEffects } from '../../node/agentSideEffects.js'; import { SessionStateManager } from '../../node/sessionStateManager.js'; import { MockAgent } from './mockAgent.js'; @@ -296,6 +296,181 @@ suite('AgentSideEffects', () => { }); }); + // ---- handleRestoreSession ----------------------------------------------- + + suite('handleRestoreSession', () => { + + test('restores a session with message history into the state manager', async () => { + // Create a session on the agent backend (not in the state manager) + const session = await agent.createSession(); + const sessions = await agent.listSessions(); + const sessionResource = sessions[0].session.toString(); + + // Set up the agent's stored messages + agent.sessionMessages = [ + { type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Hello', toolRequests: [] }, + { type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'Hi there!', toolRequests: [] }, + ]; + + // Before restore, state manager shouldn't have it + assert.strictEqual(stateManager.getSessionState(sessionResource), undefined); + + await sideEffects.handleRestoreSession(sessionResource); + + // After restore, state manager should have it + const state = stateManager.getSessionState(sessionResource); + assert.ok(state, 'session should be in state manager'); + assert.strictEqual(state!.lifecycle, SessionLifecycle.Ready); + assert.strictEqual(state!.turns.length, 1); + assert.strictEqual(state!.turns[0].userMessage.text, 'Hello'); + assert.strictEqual(state!.turns[0].responseText, 'Hi there!'); + assert.strictEqual(state!.turns[0].state, TurnState.Complete); + }); + + test('restores a session with tool calls', async () => { + const session = await agent.createSession(); + const sessions = await agent.listSessions(); + const sessionResource = sessions[0].session.toString(); + + agent.sessionMessages = [ + { type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Run a command', toolRequests: [] }, + { type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'I will run a command.', toolRequests: [{ toolCallId: 'tc-1', name: 'shell' }] }, + { type: 'tool_start', session, toolCallId: 'tc-1', toolName: 'shell', displayName: 'Shell', invocationMessage: 'Running command...' }, + { type: 'tool_complete', session, toolCallId: 'tc-1', result: { success: true, pastTenseMessage: 'Ran command', content: [{ type: ToolResultContentType.Text, text: 'output' }] } }, + { type: 'message', session, role: 'assistant', messageId: 'msg-3', content: 'Done!', toolRequests: [] }, + ]; + + await sideEffects.handleRestoreSession(sessionResource); + + const state = stateManager.getSessionState(sessionResource); + assert.ok(state); + assert.strictEqual(state!.turns.length, 1); + + const turn = state!.turns[0]; + assert.strictEqual(turn.toolCalls.length, 1); + const tc = turn.toolCalls[0] as IToolCallCompletedState; + assert.strictEqual(tc.status, ToolCallStatus.Completed); + assert.strictEqual(tc.toolCallId, 'tc-1'); + assert.strictEqual(tc.toolName, 'shell'); + assert.strictEqual(tc.displayName, 'Shell'); + assert.strictEqual(tc.success, true); + assert.strictEqual(tc.confirmed, ToolCallConfirmationReason.NotNeeded); + }); + + test('restores a session with multiple turns', async () => { + const session = await agent.createSession(); + const sessions = await agent.listSessions(); + const sessionResource = sessions[0].session.toString(); + + agent.sessionMessages = [ + { type: 'message', session, role: 'user', messageId: 'msg-1', content: 'First question', toolRequests: [] }, + { type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'First answer', toolRequests: [] }, + { type: 'message', session, role: 'user', messageId: 'msg-3', content: 'Second question', toolRequests: [] }, + { type: 'message', session, role: 'assistant', messageId: 'msg-4', content: 'Second answer', toolRequests: [] }, + ]; + + await sideEffects.handleRestoreSession(sessionResource); + + const state = stateManager.getSessionState(sessionResource); + assert.ok(state); + assert.strictEqual(state!.turns.length, 2); + assert.strictEqual(state!.turns[0].userMessage.text, 'First question'); + assert.strictEqual(state!.turns[0].responseText, 'First answer'); + assert.strictEqual(state!.turns[1].userMessage.text, 'Second question'); + assert.strictEqual(state!.turns[1].responseText, 'Second answer'); + }); + + test('flushes interrupted turns when user message arrives without closing assistant message', async () => { + const session = await agent.createSession(); + const sessions = await agent.listSessions(); + const sessionResource = sessions[0].session.toString(); + + agent.sessionMessages = [ + { type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Interrupted question', toolRequests: [] }, + // No assistant message - the turn was interrupted + { type: 'message', session, role: 'user', messageId: 'msg-2', content: 'Retried question', toolRequests: [] }, + { type: 'message', session, role: 'assistant', messageId: 'msg-3', content: 'Answer', toolRequests: [] }, + ]; + + await sideEffects.handleRestoreSession(sessionResource); + + const state = stateManager.getSessionState(sessionResource); + assert.ok(state); + assert.strictEqual(state!.turns.length, 2); + assert.strictEqual(state!.turns[0].userMessage.text, 'Interrupted question'); + assert.strictEqual(state!.turns[0].responseText, ''); + assert.strictEqual(state!.turns[0].state, TurnState.Cancelled); + assert.strictEqual(state!.turns[1].userMessage.text, 'Retried question'); + assert.strictEqual(state!.turns[1].responseText, 'Answer'); + assert.strictEqual(state!.turns[1].state, TurnState.Complete); + }); + + test('is a no-op for a session already in the state manager', async () => { + setupSession(); + // Should not throw or create a duplicate + await sideEffects.handleRestoreSession(sessionUri.toString()); + assert.ok(stateManager.getSessionState(sessionUri.toString())); + }); + + test('throws when no agent found for session', async () => { + const noAgentSideEffects = disposables.add(new AgentSideEffects(stateManager, { + getAgent: () => undefined, + agents: observableValue('agents', []), + sessionDataService: {} as ISessionDataService, + }, new NullLogService(), fileService)); + + await assert.rejects( + () => noAgentSideEffects.handleRestoreSession('unknown://session-1'), + /No agent for session/, + ); + }); + + test('response parts include markdown segments', async () => { + const session = await agent.createSession(); + const sessions = await agent.listSessions(); + const sessionResource = sessions[0].session.toString(); + + agent.sessionMessages = [ + { type: 'message', session, role: 'user', messageId: 'msg-1', content: 'hello', toolRequests: [] }, + { type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'response text', toolRequests: [] }, + ]; + + await sideEffects.handleRestoreSession(sessionResource); + + const state = stateManager.getSessionState(sessionResource); + assert.ok(state); + assert.strictEqual(state!.turns[0].responseParts.length, 1); + assert.strictEqual(state!.turns[0].responseParts[0].kind, ResponsePartKind.Markdown); + assert.strictEqual(state!.turns[0].responseParts[0].content, 'response text'); + }); + + test('throws when session is not found on backend', async () => { + // Agent exists but session is not in listSessions + await assert.rejects( + () => sideEffects.handleRestoreSession(AgentSession.uri('mock', 'nonexistent').toString()), + /Session not found on backend/, + ); + }); + + test('preserves workingDirectory from agent metadata', async () => { + agent.sessionMetadataOverrides = { workingDirectory: '/home/user/project' }; + const session = await agent.createSession(); + const sessions = await agent.listSessions(); + const sessionResource = sessions[0].session.toString(); + + agent.sessionMessages = [ + { type: 'message', session, role: 'user', messageId: 'msg-1', content: 'hi', toolRequests: [] }, + { type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'hello', toolRequests: [] }, + ]; + + await sideEffects.handleRestoreSession(sessionResource); + + const state = stateManager.getSessionState(sessionResource); + assert.ok(state); + assert.strictEqual(state!.summary.workingDirectory, '/home/user/project'); + }); + }); + // ---- handleBrowseDirectory ------------------------------------------ suite('handleBrowseDirectory', () => { diff --git a/src/vs/platform/agentHost/test/node/mockAgent.ts b/src/vs/platform/agentHost/test/node/mockAgent.ts index 70c528a19c751..051cd28eebe3e 100644 --- a/src/vs/platform/agentHost/test/node/mockAgent.ts +++ b/src/vs/platform/agentHost/test/node/mockAgent.ts @@ -7,7 +7,7 @@ import { Emitter } from '../../../../base/common/event.js'; import { URI } from '../../../../base/common/uri.js'; import type { IAuthorizationProtectedResourceMetadata } from '../../../../base/common/oauth.js'; import { AgentSession, type AgentProvider, type IAgent, type IAgentAttachment, type IAgentCreateSessionConfig, type IAgentDescriptor, type IAgentMessageEvent, type IAgentModelInfo, type IAgentProgressEvent, type IAgentSessionMetadata, type IAgentToolCompleteEvent, type IAgentToolStartEvent } from '../../common/agentService.js'; -import { PermissionKind, ToolResultContentType } from '../../common/state/sessionState.js'; +import { PermissionKind, ToolResultContentType, type IToolCallResult } from '../../common/state/sessionState.js'; /** * General-purpose mock agent for unit tests. Tracks all method calls @@ -28,6 +28,12 @@ export class MockAgent implements IAgent { readonly changeModelCalls: { session: URI; model: string }[] = []; readonly authenticateCalls: { resource: string; token: string }[] = []; + /** Configurable return value for getSessionMessages. */ + sessionMessages: (IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent)[] = []; + + /** Optional overrides applied to session metadata from listSessions. */ + sessionMetadataOverrides: Partial> = {}; + constructor(readonly id: AgentProvider = 'mock') { } getDescriptor(): IAgentDescriptor { @@ -46,7 +52,7 @@ export class MockAgent implements IAgent { } async listSessions(): Promise { - return [...this._sessions.values()].map(s => ({ session: s, startTime: Date.now(), modifiedTime: Date.now() })); + return [...this._sessions.values()].map(s => ({ session: s, startTime: Date.now(), modifiedTime: Date.now(), ...this.sessionMetadataOverrides })); } async createSession(_config?: IAgentCreateSessionConfig): Promise { @@ -61,7 +67,7 @@ export class MockAgent implements IAgent { } async getSessionMessages(_session: URI): Promise<(IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent)[]> { - return []; + return this.sessionMessages; } async disposeSession(session: URI): Promise { @@ -97,6 +103,15 @@ export class MockAgent implements IAgent { } } +/** + * Well-known URI of a pre-existing session seeded in {@link ScriptedMockAgent}. + * This session appears in `listSessions()` and has message history via + * `getSessionMessages()`, but was never created through the server's + * `handleCreateSession`. It simulates a session from a previous server + * lifetime for testing the restore-on-subscribe path. + */ +export const PRE_EXISTING_SESSION_URI = AgentSession.uri('mock', 'pre-existing-session'); + export class ScriptedMockAgent implements IAgent { readonly id: AgentProvider = 'mock'; @@ -106,11 +121,27 @@ export class ScriptedMockAgent implements IAgent { private readonly _sessions = new Map(); private _nextId = 1; + /** + * Message history for the pre-existing session: a single user→assistant + * turn with a tool call. + */ + private readonly _preExistingMessages: (IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent)[] = [ + { type: 'message', role: 'user', session: PRE_EXISTING_SESSION_URI, messageId: 'h-msg-1', content: 'What files are here?' }, + { type: 'tool_start', session: PRE_EXISTING_SESSION_URI, toolCallId: 'h-tc-1', toolName: 'list_files', displayName: 'List Files', invocationMessage: 'Listing files...' }, + { type: 'tool_complete', session: PRE_EXISTING_SESSION_URI, toolCallId: 'h-tc-1', result: { pastTenseMessage: 'Listed files', content: [{ type: ToolResultContentType.Text, text: 'file1.ts\nfile2.ts' }], success: true } satisfies IToolCallResult }, + { type: 'message', role: 'assistant', session: PRE_EXISTING_SESSION_URI, messageId: 'h-msg-2', content: 'Here are the files: file1.ts and file2.ts' }, + ]; + // Track pending permission requests private readonly _pendingPermissions = new Map void>(); // Track pending abort callbacks for slow responses private readonly _pendingAborts = new Map void>(); + constructor() { + // Seed the pre-existing session so it appears in listSessions() + this._sessions.set(AgentSession.id(PRE_EXISTING_SESSION_URI), PRE_EXISTING_SESSION_URI); + } + getDescriptor(): IAgentDescriptor { return { provider: 'mock', displayName: 'Mock Agent', description: 'Scripted test agent', requiresAuth: false }; } @@ -124,7 +155,7 @@ export class ScriptedMockAgent implements IAgent { } async listSessions(): Promise { - return [...this._sessions.values()].map(s => ({ session: s, startTime: Date.now(), modifiedTime: Date.now() })); + return [...this._sessions.values()].map(s => ({ session: s, startTime: Date.now(), modifiedTime: Date.now(), summary: s.toString() === PRE_EXISTING_SESSION_URI.toString() ? 'Pre-existing session' : undefined })); } async createSession(_config?: IAgentCreateSessionConfig): Promise { @@ -210,7 +241,10 @@ export class ScriptedMockAgent implements IAgent { } } - async getSessionMessages(_session: URI): Promise<(IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent)[]> { + async getSessionMessages(session: URI): Promise<(IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent)[]> { + if (session.toString() === PRE_EXISTING_SESSION_URI.toString()) { + return this._preExistingMessages; + } return []; } diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index 61ebd8d1513bc..9a87a5edb0275 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -75,6 +75,7 @@ class MockSideEffectHandler implements IProtocolSideEffectHandler { async handleCreateSession(_command: ICreateSessionParams): Promise { /* session created via state manager */ } handleDisposeSession(_session: string): void { } async handleListSessions(): Promise { return []; } + async handleRestoreSession(_session: string): Promise { } handleGetResourceMetadata() { return { resources: [] }; } async handleAuthenticate(_params: { resource: string; token: string }) { return { authenticated: true }; } async handleBrowseDirectory(uri: string): Promise<{ entries: { name: string; type: 'file' | 'directory' }[] }> { diff --git a/src/vs/platform/agentHost/test/node/protocolWebSocket.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocolWebSocket.integrationTest.ts index b89b3837b12fd..b1fa32a485d01 100644 --- a/src/vs/platform/agentHost/test/node/protocolWebSocket.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocolWebSocket.integrationTest.ts @@ -26,6 +26,7 @@ import { type IReconnectResult } from '../../common/state/sessionProtocol.js'; import type { ISessionState } from '../../common/state/sessionState.js'; +import { PRE_EXISTING_SESSION_URI } from './mockAgent.js'; // ---- JSON-RPC test client --------------------------------------------------- @@ -650,6 +651,48 @@ suite('Protocol WebSocket E2E', function () { assert.strictEqual(state.summary.model, 'new-mock-model'); }); + // ---- Session restore: subscribe to a session from a previous server lifetime + + test('subscribe to a pre-existing session restores turns from agent history', async function () { + this.timeout(10_000); + + await client.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-restore' }); + + // The mock agent seeds a pre-existing session that was never created + // through the server's handleCreateSession -- simulating a session + // from a previous server lifetime. + const preExistingUri = PRE_EXISTING_SESSION_URI.toString(); + const list = await client.call('listSessions'); + const preExisting = list.items.find(s => s.resource === preExistingUri); + assert.ok(preExisting, 'listSessions should include the pre-existing session'); + + // Clear notifications so we can verify no duplicate sessionAdded fires. + client.clearReceived(); + + // Subscribing to this session should trigger the restore path: the + // server fetches message history from the agent and reconstructs turns. + const result = await client.call('subscribe', { resource: preExistingUri }); + const state = result.snapshot.state as ISessionState; + + assert.strictEqual(state.lifecycle, 'ready', 'restored session should be in ready state'); + assert.ok(state.turns.length >= 1, `expected at least 1 restored turn but got ${state.turns.length}`); + + const turn = state.turns[0]; + assert.strictEqual(turn.userMessage.text, 'What files are here?'); + assert.strictEqual(turn.state, 'complete'); + assert.ok(turn.toolCalls.length >= 1, 'turn should have tool calls'); + assert.strictEqual(turn.toolCalls[0].toolName, 'list_files'); + assert.ok(turn.responseText.includes('file1.ts')); + + // Restoring should NOT emit a duplicate sessionAdded notification + // (the session is already known to clients via listSessions). + await new Promise(resolve => setTimeout(resolve, 200)); + const sessionAddedNotifs = client.receivedNotifications(n => + n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded' + ); + assert.strictEqual(sessionAddedNotifs.length, 0, 'restore should not emit sessionAdded'); + }); + test('malformed JSON message returns parse error', async function () { this.timeout(10_000); diff --git a/src/vs/platform/agentHost/test/node/sessionStateManager.test.ts b/src/vs/platform/agentHost/test/node/sessionStateManager.test.ts index 10b2db4d8c99a..77f1628c6b665 100644 --- a/src/vs/platform/agentHost/test/node/sessionStateManager.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionStateManager.test.ts @@ -9,7 +9,7 @@ import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../log/common/log.js'; import { ActionType, NotificationType, type IActionEnvelope, type INotification } from '../../common/state/sessionActions.js'; -import { ISessionSummary, ROOT_STATE_URI, SessionLifecycle, SessionStatus, type ISessionState } from '../../common/state/sessionState.js'; +import { ISessionSummary, ROOT_STATE_URI, SessionLifecycle, SessionStatus, TurnState, type ISessionState } from '../../common/state/sessionState.js'; import { SessionStateManager } from '../../node/sessionStateManager.js'; suite('SessionStateManager', () => { @@ -247,4 +247,41 @@ suite('SessionStateManager', () => { }); assert.strictEqual(manager.rootState.activeSessions, 0); }); + + test('restoreSession creates session in Ready state with pre-populated turns', () => { + const turns = [ + { + id: 'turn-1', + userMessage: { text: 'hello' }, + responseText: 'world', + responseParts: [], + toolCalls: [], + usage: undefined, + state: TurnState.Complete, + }, + ]; + + const state = manager.restoreSession(makeSessionSummary(), turns); + assert.strictEqual(state.lifecycle, SessionLifecycle.Ready); + assert.strictEqual(state.turns.length, 1); + assert.strictEqual(state.turns[0].userMessage.text, 'hello'); + assert.strictEqual(state.turns[0].responseText, 'world'); + }); + + test('restoreSession returns existing state for duplicate session', () => { + manager.createSession(makeSessionSummary()); + const existing = manager.getSessionState(sessionUri); + + const state = manager.restoreSession(makeSessionSummary(), []); + assert.strictEqual(state, existing); + }); + + test('restoreSession does not emit sessionAdded notification', () => { + const notifications: INotification[] = []; + disposables.add(manager.onDidEmitNotification(n => notifications.push(n))); + + manager.restoreSession(makeSessionSummary(), []); + + assert.strictEqual(notifications.length, 0, 'should not emit notification for restored sessions'); + }); }); From 3ad404e869cf5914f55223b0237f00a8472221b9 Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Tue, 24 Mar 2026 18:21:13 +1100 Subject: [PATCH 03/10] refactor: Copilot CLI resort to restoring model from history (#304364) * refactor: Copilot CLI resort to restoring model from history * Updates --- .../contrib/chat/browser/widget/input/chatInputPart.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index 66154a9e0c7d8..856db2a17d377 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -1177,14 +1177,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge * that was last used - providing continuity. */ private preselectModelFromSessionHistory(): void { - const sessionType = this.getCurrentSessionType(); - if (!sessionType) { - return; - } - const contribution = this.chatSessionsService.getChatSessionContribution(sessionType); - if (contribution?.useRequestToPopulateBuiltInPickers) { - return; - } const sessionResource = this._widget?.viewModel?.model.sessionResource; const ctx = sessionResource ? this.chatService.getChatSessionFromInternalUri(sessionResource) : undefined; const requiresCustomModels = ctx && this.chatSessionsService.requiresCustomModelsForSessionType(getChatSessionType(ctx.chatSessionResource)); From d1c20135ee1d724d14eb8899406ea3dade898119 Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Tue, 24 Mar 2026 18:45:18 +1100 Subject: [PATCH 04/10] refactor: Copilot CLI simplify preselectModelFromSessionHistory (#304378) --- .../chat/browser/widget/input/chatInputPart.ts | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index 856db2a17d377..9f1d95a4d9fe2 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -1177,32 +1177,18 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge * that was last used - providing continuity. */ private preselectModelFromSessionHistory(): void { - const sessionResource = this._widget?.viewModel?.model.sessionResource; - const ctx = sessionResource ? this.chatService.getChatSessionFromInternalUri(sessionResource) : undefined; - const requiresCustomModels = ctx && this.chatSessionsService.requiresCustomModelsForSessionType(getChatSessionType(ctx.chatSessionResource)); - if (!requiresCustomModels) { - return; - } - const requests = this._widget?.viewModel?.model.getRequests(); if (!requests || requests.length === 0) { return; } - // Find the modelId from the last request that has one - let lastModelId: string | undefined; - for (let i = requests.length - 1; i >= 0; i--) { - if (requests[i].modelId) { - lastModelId = requests[i].modelId; - break; - } - } - const modeInfo = findLast(requests, req => !!req.modeInfo)?.modeInfo; if (modeInfo && modeInfo.modeInstructions?.uri) { this.setChatMode(modeInfo.modeInstructions.uri.toString()); } + // Find the modelId from the last request that has one + const lastModelId = findLast(requests, req => !!req.modelId)?.modelId; if (!lastModelId) { return; } From 7674c073de028d7aa9cae5b4301864a60a028bf8 Mon Sep 17 00:00:00 2001 From: Johannes Rieken Date: Tue, 24 Mar 2026 09:35:49 +0100 Subject: [PATCH 05/10] fix: update TPI_CREATION variable reference in GitHub issues queries (#304389) --- .vscode/notebooks/endgame.github-issues | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.vscode/notebooks/endgame.github-issues b/.vscode/notebooks/endgame.github-issues index de002bfa94ef4..c039ae27ce854 100644 --- a/.vscode/notebooks/endgame.github-issues +++ b/.vscode/notebooks/endgame.github-issues @@ -7,7 +7,7 @@ { "kind": 2, "language": "github-issues", - "value": "$MILESTONE=milestone:\"1.113.0\"" + "value": "$MILESTONE=milestone:\"1.113.0\"\n\n$TPI_CREATION=2026-03-23 // Used to find fixes that need to be verified" }, { "kind": 1, @@ -47,6 +47,6 @@ { "kind": 2, "language": "github-issues", - "value": "org:microsoft $MILESTONE is:issue is:closed label:bug reason:completed -label:verified created:>=2026-03-23" + "value": "org:microsoft $MILESTONE is:issue is:closed label:bug reason:completed -label:verified created:>=$TPI_CREATION" } ] \ No newline at end of file From bb77888173be835393240c75d0a63553a02a069e Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Tue, 24 Mar 2026 09:57:28 +0100 Subject: [PATCH 06/10] Fix Windows agent harness links in postinstall (#304392) * Fix Windows agent harness links in postinstall * Update build/npm/postinstall.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- build/npm/postinstall.ts | 38 ++++++++++++++++++++++++++++++++------ 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/build/npm/postinstall.ts b/build/npm/postinstall.ts index 5ab9d682857cc..db659fa78a423 100644 --- a/build/npm/postinstall.ts +++ b/build/npm/postinstall.ts @@ -182,6 +182,32 @@ function clearInheritedNpmrcConfig(dir: string, env: NodeJS.ProcessEnv): void { } } +function ensureAgentHarnessLink(sourceRelativePath: string, linkPath: string): 'existing' | 'junction' | 'symlink' | 'hard link' { + if (fs.existsSync(linkPath)) { + return 'existing'; + } + + const sourcePath = path.resolve(path.dirname(linkPath), sourceRelativePath); + const isDirectory = fs.statSync(sourcePath).isDirectory(); + + try { + if (process.platform === 'win32' && isDirectory) { + fs.symlinkSync(sourcePath, linkPath, 'junction'); + return 'junction'; + } + + fs.symlinkSync(sourceRelativePath, linkPath, isDirectory ? 'dir' : 'file'); + return 'symlink'; + } catch (error) { + if (process.platform === 'win32' && !isDirectory && (error as NodeJS.ErrnoException).code === 'EPERM') { + fs.linkSync(sourcePath, linkPath); + return 'hard link'; + } + + throw error; + } +} + async function runWithConcurrency(tasks: (() => Promise)[], concurrency: number): Promise { const errors: Error[] = []; let index = 0; @@ -294,15 +320,15 @@ async function main() { fs.mkdirSync(claudeDir, { recursive: true }); const claudeMdLink = path.join(claudeDir, 'CLAUDE.md'); - if (!fs.existsSync(claudeMdLink)) { - fs.symlinkSync(path.join('..', '.github', 'copilot-instructions.md'), claudeMdLink); - log('.', 'Symlinked .claude/CLAUDE.md -> .github/copilot-instructions.md'); + const claudeMdLinkType = ensureAgentHarnessLink(path.join('..', '.github', 'copilot-instructions.md'), claudeMdLink); + if (claudeMdLinkType !== 'existing') { + log('.', `Created ${claudeMdLinkType} .claude/CLAUDE.md -> .github/copilot-instructions.md`); } const claudeSkillsLink = path.join(claudeDir, 'skills'); - if (!fs.existsSync(claudeSkillsLink)) { - fs.symlinkSync(path.join('..', '.agents', 'skills'), claudeSkillsLink); - log('.', 'Symlinked .claude/skills -> .agents/skills'); + const claudeSkillsLinkType = ensureAgentHarnessLink(path.join('..', '.agents', 'skills'), claudeSkillsLink); + if (claudeSkillsLinkType !== 'existing') { + log('.', `Created ${claudeSkillsLinkType} .claude/skills -> .agents/skills`); } // Temporary: patch @github/copilot-sdk session.js to fix ESM import From fc6b2053dd7da741e2012b8cac51612db63934f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Moreno?= Date: Tue, 24 Mar 2026 10:37:56 +0100 Subject: [PATCH 07/10] fix: trigger stable build instead of insider build on release branches (#304406) Co-authored-by: Copilot --- build/azure-pipelines/product-build.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/build/azure-pipelines/product-build.yml b/build/azure-pipelines/product-build.yml index c81783f3d1d57..fa1cc1a7699fa 100644 --- a/build/azure-pipelines/product-build.yml +++ b/build/azure-pipelines/product-build.yml @@ -740,15 +740,15 @@ extends: VSCODE_RELEASE: ${{ parameters.VSCODE_RELEASE }} - ${{ if and(in(variables['Build.Reason'], 'IndividualCI', 'BatchedCI'), startsWith(variables['Build.SourceBranch'], 'refs/heads/release/')) }}: - - stage: TriggerInsiderBuild - displayName: Trigger Insider Build + - stage: TriggerStableBuild + displayName: Trigger Stable Build dependsOn: [] pool: name: 1es-ubuntu-22.04-x64 os: linux jobs: - - job: TriggerInsiderBuild - displayName: Trigger Insider Build + - job: TriggerStableBuild + displayName: Trigger Stable Build steps: - checkout: none - script: | @@ -759,9 +759,9 @@ extends: definition: { id: Number(process.env.DEFINITION_ID) }, sourceBranch: process.env.SOURCE_BRANCH, sourceVersion: process.env.SOURCE_VERSION, - templateParameters: { VSCODE_QUALITY: "insider", VSCODE_RELEASE: "false" } + templateParameters: { VSCODE_QUALITY: "stable", VSCODE_RELEASE: "false" } }); - console.log(`Triggering insider build on ${process.env.SOURCE_BRANCH} @ ${process.env.SOURCE_VERSION}...`); + console.log(`Triggering stable build on ${process.env.SOURCE_BRANCH} @ ${process.env.SOURCE_VERSION}...`); const response = await fetch(process.env.BUILDS_API_URL, { method: "POST", headers: { "Authorization": `Bearer ${process.env.SYSTEM_ACCESSTOKEN}`, "Content-Type": "application/json" }, @@ -775,7 +775,7 @@ extends: } main().catch(err => { console.error(err); process.exit(1); }); ' - displayName: Queue insider build + displayName: Queue stable build env: SYSTEM_ACCESSTOKEN: $(System.AccessToken) DEFINITION_ID: $(System.DefinitionId) From 49610e6f17071d48eca7efd6b3d620c1953195ed Mon Sep 17 00:00:00 2001 From: BeniBenj Date: Tue, 24 Mar 2026 10:55:22 +0100 Subject: [PATCH 08/10] Revert "Session grouping api" This reverts commit 9ea434f320772067147bf9cb43c84a68c6c9c817. --- .../browser/sessionsManagementService.ts | 8 +++--- .../api/common/extHostTypeConverters.ts | 3 --- .../chat/common/chatService/chatService.ts | 12 ++------- .../common/chatService/chatServiceImpl.ts | 1 - .../contrib/chat/common/model/chatModel.ts | 4 +-- .../chat/common/participants/chatAgents.ts | 6 +---- .../common/chatService/chatService.test.ts | 26 ------------------- .../chat/test/common/model/chatModel.test.ts | 3 +-- ...scode.proposed.chatParticipantPrivate.d.ts | 11 -------- 9 files changed, 8 insertions(+), 66 deletions(-) diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts index 433e61b518009..c2b35c960f716 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts @@ -15,7 +15,7 @@ import { IStorageService, StorageScope, StorageTarget } from '../../../../platfo import { ISessionOpenOptions, openSession as openSessionDefault } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.js'; import { ChatViewPaneTarget, IChatWidget, IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; import { IChatSessionProviderOptionItem, IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; -import { IChatService, IChatSendRequestOptions, IChatSessionGrouping } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; +import { IChatService, IChatSendRequestOptions } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { ChatAgentLocation, ChatModeKind, ChatPermissionLevel } from '../../../../workbench/contrib/chat/common/constants.js'; import { IAgentSession, isAgentSession } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js'; import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; @@ -29,7 +29,6 @@ import { ILanguageModelToolsService } from '../../../../workbench/contrib/chat/c import { GITHUB_REMOTE_FILE_SCHEME } from '../common/sessionWorkspace.js'; import { IGitHubSessionContext } from '../../github/common/types.js'; import { ResourceSet } from '../../../../base/common/map.js'; -import { generateUuid } from '../../../../base/common/uuid.js'; export const IsNewChatSessionContext = new RawContextKey('isNewChatSession', true); @@ -99,7 +98,7 @@ export interface ISessionsManagementService { * Open a new session, apply options, and send the initial request. * Looks up the session by resource URI and builds send options from it. */ - sendRequestForNewSession(sessionResource: URI, options?: { permissionLevel?: ChatPermissionLevel; sessionGrouping?: IChatSessionGrouping }): Promise; + sendRequestForNewSession(sessionResource: URI, options?: { permissionLevel?: ChatPermissionLevel }): Promise; /** * Commit files in a worktree and refresh the agent sessions model @@ -284,7 +283,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa return newSession; } - async sendRequestForNewSession(sessionResource: URI, options?: { permissionLevel?: ChatPermissionLevel; sessionGrouping?: IChatSessionGrouping }): Promise { + async sendRequestForNewSession(sessionResource: URI, options?: { permissionLevel?: ChatPermissionLevel }): Promise { const session = this._newSession.value; if (!session) { this.logService.error(`[SessionsManagementService] No new session found for resource: ${sessionResource.toString()}`); @@ -330,7 +329,6 @@ export class SessionsManagementService extends Disposable implements ISessionsMa }, agentIdSilent: contribution?.type, attachedContext: session.attachedContext, - sessionGrouping: options?.sessionGrouping ?? { id: generateUuid(), order: 0 }, }; await this.chatSessionsService.getOrCreateChatSession(session.resource, CancellationToken.None); diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index 7c5949ab1318f..3ed289de7e1e1 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -3447,7 +3447,6 @@ export namespace ChatAgentRequest { subAgentInvocationId: request.subAgentInvocationId, subAgentName: request.subAgentName, parentRequestId: request.parentRequestId, - sessionGrouping: request.sessionGrouping, hasHooksEnabled: request.hasHooksEnabled ?? false, hooks: request.hooks ? ChatRequestHooksConverter.to(request.hooks) : undefined, }; @@ -3476,8 +3475,6 @@ export namespace ChatAgentRequest { // eslint-disable-next-line local/code-no-any-casts delete (requestWithAllProps as any).parentRequestId; // eslint-disable-next-line local/code-no-any-casts - delete (requestWithAllProps as any).sessionGrouping; - // eslint-disable-next-line local/code-no-any-casts delete (requestWithAllProps as any).hasHooksEnabled; // eslint-disable-next-line local/code-no-any-casts delete (requestWithAllProps as any).hooks; diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index bdf87f668088f..26426bb321338 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -1368,12 +1368,6 @@ export const enum ChatRequestQueueKind { Steering = 'steering' } -export interface IChatSessionGrouping { - readonly id: string; - readonly order: number; - readonly kind?: string; -} - export interface IChatSendRequestOptions { modeInfo?: IChatRequestModeInfo; userSelectedModelId?: string; @@ -1398,7 +1392,7 @@ export interface IChatSendRequestOptions { /** * The label of the confirmation action that was selected. - */ + */ confirmation?: string; /** @@ -1410,11 +1404,9 @@ export interface IChatSendRequestOptions { /** * When true, the queued request will not be processed immediately even if no request is active. * The request stays in the queue until `processPendingRequests` is called explicitly. - */ + */ pauseQueue?: boolean; - /** Optional metadata for grouping related requests together in the UI, e.g. for sub-agent interactions */ - sessionGrouping?: IChatSessionGrouping; } export type IChatModelReference = IReference; diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index 8867a746b59de..265fec2396457 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -1170,7 +1170,6 @@ export class ChatService extends Disposable implements IChatService { modeInstructions: options?.modeInfo?.modeInstructions, permissionLevel: options?.modeInfo?.permissionLevel, editedFileEvents: request.editedFileEvents, - sessionGrouping: options?.sessionGrouping, hooks: collectedHooks, hasHooksEnabled: !!collectedHooks && Object.values(collectedHooks).some(arr => arr.length > 0), }; diff --git a/src/vs/workbench/contrib/chat/common/model/chatModel.ts b/src/vs/workbench/contrib/chat/common/model/chatModel.ts index ac408e83a300b..e5ce38c5fea0d 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatModel.ts @@ -29,7 +29,7 @@ import { ILogService } from '../../../../../platform/log/common/log.js'; import { CellUri, ICellEditOperation } from '../../../notebook/common/notebookCommon.js'; import { ChatRequestToolReferenceEntry, IChatRequestVariableEntry, isImplicitVariableEntry, isStringImplicitContextValue, isStringVariableEntry } from '../attachments/chatVariableEntries.js'; import { migrateLegacyTerminalToolSpecificData } from '../chat.js'; -import { ChatAgentVoteDirection, ChatAgentVoteDownReason, ChatRequestQueueKind, ChatResponseClearToPreviousToolInvocationReason, ElicitationState, IChatAgentMarkdownContentWithVulnerability, IChatClearToPreviousToolInvocation, IChatCodeCitation, IChatCommandButton, IChatConfirmation, IChatContentInlineReference, IChatContentReference, IChatDisabledClaudeHooksPart, IChatEditingSessionAction, IChatElicitationRequest, IChatElicitationRequestSerialized, IChatExternalToolInvocationUpdate, IChatExtensionsContent, IChatFollowup, IChatHookPart, IChatLocationData, IChatMarkdownContent, IChatMcpServersStarting, IChatMcpServersStartingSerialized, IChatModelReference, IChatMultiDiffData, IChatMultiDiffDataSerialized, IChatNotebookEdit, IChatProgress, IChatProgressMessage, IChatPullRequestContent, IChatQuestionCarousel, IChatResponseCodeblockUriPart, IChatResponseProgressFileTreeData, IChatSendRequestOptions, IChatService, IChatSessionContext, IChatSessionGrouping, IChatSessionTiming, IChatTask, IChatTaskSerialized, IChatTextEdit, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized, IChatTreeData, IChatUndoStop, IChatUsage, IChatUsedContext, IChatWarningMessage, IChatWorkspaceEdit, ResponseModelState, isIUsedContext } from '../chatService/chatService.js'; +import { ChatAgentVoteDirection, ChatAgentVoteDownReason, ChatRequestQueueKind, ChatResponseClearToPreviousToolInvocationReason, ElicitationState, IChatAgentMarkdownContentWithVulnerability, IChatClearToPreviousToolInvocation, IChatCodeCitation, IChatCommandButton, IChatConfirmation, IChatContentInlineReference, IChatContentReference, IChatDisabledClaudeHooksPart, IChatEditingSessionAction, IChatElicitationRequest, IChatElicitationRequestSerialized, IChatExternalToolInvocationUpdate, IChatExtensionsContent, IChatFollowup, IChatHookPart, IChatLocationData, IChatMarkdownContent, IChatMcpServersStarting, IChatMcpServersStartingSerialized, IChatModelReference, IChatMultiDiffData, IChatMultiDiffDataSerialized, IChatNotebookEdit, IChatProgress, IChatProgressMessage, IChatPullRequestContent, IChatQuestionCarousel, IChatResponseCodeblockUriPart, IChatResponseProgressFileTreeData, IChatSendRequestOptions, IChatService, IChatSessionContext, IChatSessionTiming, IChatTask, IChatTaskSerialized, IChatTextEdit, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized, IChatTreeData, IChatUndoStop, IChatUsage, IChatUsedContext, IChatWarningMessage, IChatWorkspaceEdit, ResponseModelState, isIUsedContext } from '../chatService/chatService.js'; import { ChatAgentLocation, ChatModeKind, ChatPermissionLevel } from '../constants.js'; import { ChatToolInvocation } from './chatProgressTypes/chatToolInvocation.js'; import { ToolDataSource, IToolData } from '../tools/languageModelToolsService.js'; @@ -71,7 +71,6 @@ export interface ISerializableSendOptions { agentIdSilent?: string; slashCommand?: string; confirmation?: string; - sessionGrouping?: IChatSessionGrouping; } /** @@ -2778,7 +2777,6 @@ export function serializeSendOptions(options: IChatSendRequestOptions): ISeriali agentIdSilent: options.agentIdSilent, slashCommand: options.slashCommand, confirmation: options.confirmation, - sessionGrouping: options.sessionGrouping, }; } diff --git a/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts b/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts index e6de255b5668d..e6cdfd9e005ad 100644 --- a/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts +++ b/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts @@ -24,7 +24,7 @@ import { ChatContextKeys } from '../actions/chatContextKeys.js'; import { IChatAgentEditedFileEvent, IChatProgressHistoryResponseContent, IChatRequestModeInstructions, IChatRequestVariableData, ISerializableChatAgentData } from '../model/chatModel.js'; import { ChatRequestHooks } from '../promptSyntax/hookSchema.js'; import { IRawChatCommandContribution } from './chatParticipantContribTypes.js'; -import { IChatFollowup, IChatLocationData, IChatProgress, IChatResponseErrorDetails, IChatSessionGrouping, IChatTaskDto } from '../chatService/chatService.js'; +import { IChatFollowup, IChatLocationData, IChatProgress, IChatResponseErrorDetails, IChatTaskDto } from '../chatService/chatService.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind, ChatPermissionLevel } from '../constants.js'; import { ILanguageModelsService } from '../languageModels.js'; @@ -178,10 +178,6 @@ export interface IChatAgentRequest { * The request ID of the parent request that invoked this subagent. */ parentRequestId?: string; - /** - * Optional metadata used to group related requests together in the UI. - */ - sessionGrouping?: IChatSessionGrouping; } diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts index d65a21121e9cb..c4dacb71de5a9 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts @@ -292,32 +292,6 @@ suite('ChatService', () => { await assertSnapshot(toSnapshotExportData(model)); }); - test('sendRequest forwards sessionGrouping to the agent request', async () => { - const receivedSessionGroupings: Array<{ readonly id: string; readonly order: number; readonly kind?: string } | undefined> = []; - const groupAwareAgent: IChatAgentImplementation = { - async invoke(request) { - receivedSessionGroupings.push(request.sessionGrouping); - return {}; - }, - }; - - testDisposables.add(chatAgentService.registerAgent('groupAwareAgent', { ...getAgentData('groupAwareAgent'), isDefault: true })); - testDisposables.add(chatAgentService.registerAgentImplementation('groupAwareAgent', groupAwareAgent)); - - const testService = createChatService(); - const modelRef = testDisposables.add(startSessionModel(testService)); - const model = modelRef.object; - - const response = await testService.sendRequest(model.sessionResource, 'test request', { - agentId: 'groupAwareAgent', - sessionGrouping: { id: 'group-123', order: 2, kind: 'subagent' } - }); - ChatSendResult.assertSent(response); - await response.data.responseCompletePromise; - - assert.deepStrictEqual(receivedSessionGroupings, [{ id: 'group-123', order: 2, kind: 'subagent' }]); - }); - test('history', async () => { const historyLengthAgent: IChatAgentImplementation = { async invoke(request, progress, history, token) { diff --git a/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts b/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts index feb6f481f6295..8a3a743920751 100644 --- a/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts @@ -1099,13 +1099,12 @@ suite('ChatModel - Pending Requests', () => { test('pending requests preserve send options', () => { const model = createModel(); const request = addRequestToModel(model, 'test'); - const sendOptions = { agentId: 'test-agent', attempt: 3, sessionGrouping: { id: 'group-123', order: 1, kind: 'subagent' } }; + const sendOptions = { agentId: 'test-agent', attempt: 3 }; const pending = model.addPendingRequest(request, ChatRequestQueueKind.Queued, sendOptions); assert.strictEqual(pending.sendOptions.agentId, 'test-agent'); assert.strictEqual(pending.sendOptions.attempt, 3); - assert.deepStrictEqual(pending.sendOptions.sessionGrouping, { id: 'group-123', order: 1, kind: 'subagent' }); }); }); diff --git a/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts b/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts index ba9dee1439d1b..733355350961b 100644 --- a/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts +++ b/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts @@ -50,12 +50,6 @@ declare module 'vscode' { constructor(cell: TextDocument); } - export interface ChatRequestSessionGrouping { - readonly id: string; - readonly order: number; - readonly kind?: string; - } - export interface ChatRequest { /** * The id of the chat request. Used to identity an interaction with any of the chat surfaces. @@ -122,11 +116,6 @@ declare module 'vscode' { */ readonly parentRequestId?: string; - /** - * Optional metadata used to group related requests together in the UI. - */ - readonly sessionGrouping?: ChatRequestSessionGrouping; - /** * The permission level for tool auto-approval in this request. * - `'autoApprove'`: Auto-approve all tool calls and retry on errors. From de4d27aa3b7f0c9156971afcbd1383e89d5d9bda Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Tue, 24 Mar 2026 10:56:22 +0100 Subject: [PATCH 09/10] Sessions: Use ITaskService to run tasks instead of terminal commands (#304397) Use ITaskService to run tasks in sessions window Replace the hacky terminal-based task execution (creating a terminal and sending commands directly) with proper ITaskService.run() delegation. This ensures all task types are supported on all platforms, not just shell and npm tasks. Changes: - runTask now looks up the task by label via ITaskService.getTask() using the workspace folder for the session worktree, then runs it via ITaskService.run() - Removed _resolveCommand, _appendArgs, _getExistingTerminalInstance and the _taskTerminals cache (no longer needed) - Removed _SUPPORTED_TASK_TYPES filter since the task service handles all task types - Replaced ITerminalService dependency with ITaskService and IWorkspaceContextService - Updated tests to mock ITaskService and IWorkspaceContextService Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/sessionsConfigurationService.ts | 100 ++-------- .../sessionsConfigurationService.test.ts | 186 ++++++------------ 2 files changed, 72 insertions(+), 214 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/sessionsConfigurationService.ts b/src/vs/sessions/contrib/chat/browser/sessionsConfigurationService.ts index fe95da3966f5c..89486a53fd935 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsConfigurationService.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsConfigurationService.ts @@ -7,17 +7,17 @@ import { Disposable, DisposableStore, MutableDisposable } from '../../../../base import { autorun, IObservable, observableValue, transaction } from '../../../../base/common/observable.js'; import { joinPath, dirname, isEqual } from '../../../../base/common/resources.js'; import { parse } from '../../../../base/common/jsonc.js'; -import { isMacintosh, isWindows } from '../../../../base/common/platform.js'; import { URI } from '../../../../base/common/uri.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { IFileService } from '../../../../platform/files/common/files.js'; -import { TerminalLocation } from '../../../../platform/terminal/common/terminal.js'; +import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; import { IActiveSessionItem, ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; import { IJSONEditingService } from '../../../../workbench/services/configuration/common/jsonEditing.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IPreferencesService } from '../../../../workbench/services/preferences/common/preferences.js'; -import { ITerminalInstance, ITerminalService } from '../../../../workbench/contrib/terminal/browser/terminal.js'; import { CommandString } from '../../../../workbench/contrib/tasks/common/taskConfiguration.js'; +import { TaskRunSource } from '../../../../workbench/contrib/tasks/common/tasks.js'; +import { ITaskService } from '../../../../workbench/contrib/tasks/common/taskService.js'; export type TaskStorageTarget = 'user' | 'workspace'; type TaskRunOnOption = 'default' | 'folderOpen' | 'worktreeCreated'; @@ -102,8 +102,8 @@ export interface ISessionsConfigurationService { removeTask(taskLabel: string, session: IActiveSessionItem, target: TaskStorageTarget): Promise; /** - * Runs a task entry in a terminal, resolving the correct platform - * command and using the session worktree as cwd. + * Runs a task via the task service, looking it up by label in the + * workspace folder corresponding to the session worktree. */ runTask(task: ITaskEntry, session: IActiveSessionItem): Promise; @@ -125,12 +125,8 @@ export class SessionsConfigurationService extends Disposable implements ISession declare readonly _serviceBrand: undefined; private static readonly _PINNED_TASK_LABELS_KEY = 'agentSessions.pinnedTaskLabels'; - private static readonly _SUPPORTED_TASK_TYPES = new Set(['shell', 'npm']); - private readonly _sessionTasks = observableValue(this, []); private readonly _fileWatcher = this._register(new MutableDisposable()); - /** Maps `cwd.toString() + command` to the terminal `instanceId`. */ - private readonly _taskTerminals = new Map(); private readonly _knownSessionWorktrees = new Map(); private readonly _pinnedTaskLabels: Map; private readonly _pinnedTaskObservables = new Map>>(); @@ -142,7 +138,8 @@ export class SessionsConfigurationService extends Disposable implements ISession @IFileService private readonly _fileService: IFileService, @IJSONEditingService private readonly _jsonEditingService: IJSONEditingService, @IPreferencesService private readonly _preferencesService: IPreferencesService, - @ITerminalService private readonly _terminalService: ITerminalService, + @ITaskService private readonly _taskService: ITaskService, + @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, @IStorageService private readonly _storageService: IStorageService, ) { @@ -328,29 +325,22 @@ export class SessionsConfigurationService extends Disposable implements ISession } async runTask(task: ITaskEntry, session: IActiveSessionItem): Promise { - const command = this._resolveCommand(task); - if (!command) { + const cwd = session.worktree ?? session.repository; + if (!cwd) { return; } - const cwd = session.worktree ?? session.repository; - if (!cwd) { + const workspaceFolder = this._workspaceContextService.getWorkspaceFolder(cwd); + if (!workspaceFolder) { return; } - const terminalKey = `${cwd.toString()}${command}`; - let terminal = this._getExistingTerminalInstance(terminalKey); - if (!terminal) { - terminal = await this._terminalService.createTerminal({ - location: TerminalLocation.Panel, - config: { name: task.label }, - cwd - }); - this._taskTerminals.set(terminalKey, terminal.instanceId); + const resolvedTask = await this._taskService.getTask(workspaceFolder, task.label); + if (!resolvedTask) { + return; } - await terminal.sendText(command, true); - this._terminalService.setActiveInstance(terminal); - await this._terminalService.revealActiveTerminal(); + + await this._taskService.run(resolvedTask, undefined, TaskRunSource.User); } getPinnedTaskLabel(repository: URI | undefined): IObservable { @@ -377,19 +367,6 @@ export class SessionsConfigurationService extends Disposable implements ISession // --- private helpers --- - private _getExistingTerminalInstance(terminalKey: string): ITerminalInstance | undefined { - const instanceId = this._taskTerminals.get(terminalKey); - if (instanceId === undefined) { - return undefined; - } - const instance = this._terminalService.instances.find(i => i.instanceId === instanceId); - if (!instance || instance.hasChildProcesses) { - this._taskTerminals.delete(terminalKey); - return undefined; - } - return instance; - } - private _getTasksJsonUri(session: IActiveSessionItem, target: TaskStorageTarget): URI | undefined { if (target === 'workspace') { const folder = session.worktree ?? session.repository; @@ -432,50 +409,7 @@ export class SessionsConfigurationService extends Disposable implements ISession } private _isSupportedTask(task: ITaskEntry): boolean { - return !!task.type && SessionsConfigurationService._SUPPORTED_TASK_TYPES.has(task.type); - } - - private _resolveCommand(task: ITaskEntry): string | undefined { - if (task.type === 'npm') { - if (!task.script) { - return undefined; - } - const base = task.path - ? `npm --prefix ${task.path} run ${task.script}` - : `npm run ${task.script}`; - return this._appendArgs(base, task.args); - } - - let command: string | undefined; - let platformArgs: CommandString[] | undefined; - - if (isWindows && task.windows?.command) { - command = task.windows.command; - platformArgs = task.windows.args; - } else if (isMacintosh && task.osx?.command) { - command = task.osx.command; - platformArgs = task.osx.args; - } else if (!isWindows && !isMacintosh && task.linux?.command) { - command = task.linux.command; - platformArgs = task.linux.args; - } else { - command = task.command; - } - - // Platform-specific args override task-level args - const args = platformArgs ?? task.args; - return this._appendArgs(command, args); - } - - private _appendArgs(command: string | undefined, args: CommandString[] | undefined): string | undefined { - if (!command) { - return undefined; - } - if (!args || args.length === 0) { - return command; - } - const resolvedArgs = args.map(a => CommandString.value(a)).join(' '); - return `${command} ${resolvedArgs}`; + return !!task.label; } private _handleActiveSessionChange(session: IActiveSessionItem | undefined): void { diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionsConfigurationService.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionsConfigurationService.test.ts index 07b0938414f0e..5569ec2600e5d 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionsConfigurationService.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionsConfigurationService.test.ts @@ -13,11 +13,13 @@ import { IFileContent, IFileService } from '../../../../../platform/files/common import { InMemoryStorageService, IStorageService } from '../../../../../platform/storage/common/storage.js'; import { IJSONEditingService, IJSONValue } from '../../../../../workbench/services/configuration/common/jsonEditing.js'; import { IPreferencesService } from '../../../../../workbench/services/preferences/common/preferences.js'; -import { ITerminalInstance, ITerminalService } from '../../../../../workbench/contrib/terminal/browser/terminal.js'; +import { IWorkspaceContextService, IWorkspaceFolder } from '../../../../../platform/workspace/common/workspace.js'; import { IActiveSessionItem, ISessionsManagementService } from '../../../sessions/browser/sessionsManagementService.js'; import { INonSessionTaskEntry, ISessionsConfigurationService, SessionsConfigurationService, ITaskEntry } from '../../browser/sessionsConfigurationService.js'; import { VSBuffer } from '../../../../../base/common/buffer.js'; import { observableValue } from '../../../../../base/common/observable.js'; +import { Task } from '../../../../../workbench/contrib/tasks/common/tasks.js'; +import { ITaskService } from '../../../../../workbench/contrib/tasks/common/taskService.js'; function makeSession(opts: { repository?: URI; worktree?: URI } = {}): IActiveSessionItem { return { @@ -53,12 +55,13 @@ suite('SessionsConfigurationService', () => { let service: ISessionsConfigurationService; let fileContents: Map; let jsonEdits: { uri: URI; values: IJSONValue[] }[]; - let createdTerminals: { name: string | undefined; cwd: URI | string | undefined }[]; - let sentCommands: { command: string }[]; + let ranTasks: { label: string }[]; let committedFiles: { session: IActiveSessionItem; fileUris: URI[] }[]; let storageService: InMemoryStorageService; let readFileCalls: URI[]; let activeSessionObs: ReturnType>; + let tasksByLabel: Map; + let workspaceFoldersByUri: Map; const userSettingsUri = URI.parse('file:///user/settings.json'); const repoUri = URI.parse('file:///repo'); @@ -67,10 +70,11 @@ suite('SessionsConfigurationService', () => { setup(() => { fileContents = new Map(); jsonEdits = []; - createdTerminals = []; - sentCommands = []; + ranTasks = []; committedFiles = []; readFileCalls = []; + tasksByLabel = new Map(); + workspaceFoldersByUri = new Map(); const instantiationService = store.add(new TestInstantiationService()); activeSessionObs = observableValue('activeSession', undefined); @@ -98,28 +102,24 @@ suite('SessionsConfigurationService', () => { override userSettingsResource = userSettingsUri; }); - let nextInstanceId = 1; - const terminalInstances: (Partial & { instanceId: number })[] = []; - - const terminalServiceMock = new class extends mock() { - override get instances(): readonly ITerminalInstance[] { return terminalInstances as ITerminalInstance[]; } - override async createTerminal(opts?: { config?: { name?: string }; cwd?: URI }) { - const instance: Partial & { instanceId: number } = { - instanceId: nextInstanceId++, - initialCwd: opts?.cwd?.fsPath, - cwd: opts?.cwd?.fsPath, - hasChildProcesses: false, - sendText: async (text: string) => { sentCommands.push({ command: text }); }, - }; - createdTerminals.push({ name: opts?.config?.name, cwd: opts?.cwd }); - terminalInstances.push(instance); - return instance as ITerminalInstance; + instantiationService.stub(ITaskService, new class extends mock() { + override async getTask(_workspaceFolder: any, alias: string | any) { + const label = typeof alias === 'string' ? alias : ''; + return tasksByLabel.get(label); } - override setActiveInstance() { } - override async revealActiveTerminal() { } - }; + override async run(task: Task | undefined) { + if (task) { + ranTasks.push({ label: task._label }); + } + return undefined; + } + }); - instantiationService.stub(ITerminalService, terminalServiceMock); + instantiationService.stub(IWorkspaceContextService, new class extends mock() { + override getWorkspaceFolder(resource: URI): IWorkspaceFolder | null { + return workspaceFoldersByUri.get(resource.toString()) ?? null; + } + }); instantiationService.stub(ISessionsManagementService, new class extends mock() { override activeSession = activeSessionObs; @@ -160,7 +160,7 @@ suite('SessionsConfigurationService', () => { await new Promise(r => setTimeout(r, 10)); const tasks = obs.get(); - assert.deepStrictEqual(tasks.map(t => t.task.label), ['build', 'test', 'watch']); + assert.deepStrictEqual(tasks.map(t => t.task.label), ['build', 'test', 'watch', 'gulp-task']); }); test('getSessionTasks returns empty array when no worktree', async () => { @@ -211,7 +211,7 @@ suite('SessionsConfigurationService', () => { // --- getNonSessionTasks --- - test('getNonSessionTasks returns only tasks without inSessions and with supported types', async () => { + test('getNonSessionTasks returns only tasks without inSessions', async () => { const worktreeTasksUri = URI.parse('file:///worktree/.vscode/tasks.json'); fileContents.set(worktreeTasksUri.toString(), tasksJsonContent([ makeTask('build', 'npm run build', true), @@ -226,7 +226,7 @@ suite('SessionsConfigurationService', () => { const session = makeSession({ worktree: worktreeUri, repository: repoUri }); const nonSessionTasks = await service.getNonSessionTasks(session); - assert.deepStrictEqual(nonSessionTasks.map(t => t.task.label), ['lint', 'test', 'watch']); + assert.deepStrictEqual(nonSessionTasks.map(t => t.task.label), ['lint', 'test', 'watch', 'gulp-task']); }); test('getNonSessionTasks reads from repository when no worktree', async () => { @@ -567,133 +567,57 @@ suite('SessionsConfigurationService', () => { // --- runTask --- - test('runTask creates terminal and sends command', async () => { - const session = makeSession({ worktree: worktreeUri, repository: repoUri }); - const task = makeTask('build', 'npm run build'); - - await service.runTask(task, session); - - assert.strictEqual(createdTerminals.length, 1); - assert.strictEqual(createdTerminals[0].name, 'build'); - assert.strictEqual(sentCommands.length, 1); - assert.strictEqual(sentCommands[0].command, 'npm run build'); - }); - - test('runTask resolves npm task to npm run