diff --git a/build/azure-pipelines/darwin/helper-entitlements.plist b/build/azure-pipelines/darwin/helper-entitlements.plist new file mode 100644 index 0000000000000..4efe1ce508f85 --- /dev/null +++ b/build/azure-pipelines/darwin/helper-entitlements.plist @@ -0,0 +1,8 @@ + + + + + com.apple.security.cs.allow-jit + + + diff --git a/build/darwin/sign.ts b/build/darwin/sign.ts index 26e22aee08c88..ed12a46473ace 100644 --- a/build/darwin/sign.ts +++ b/build/darwin/sign.ts @@ -25,6 +25,8 @@ function getEntitlementsForFile(filePath: string): string { return path.join(baseDir, 'azure-pipelines', 'darwin', 'helper-renderer-entitlements.plist'); } else if (filePath.includes(' Helper (Plugin).app')) { return path.join(baseDir, 'azure-pipelines', 'darwin', 'helper-plugin-entitlements.plist'); + } else if (filePath.includes(' Helper.app')) { + return path.join(baseDir, 'azure-pipelines', 'darwin', 'helper-entitlements.plist'); } return path.join(baseDir, 'azure-pipelines', 'darwin', 'app-entitlements.plist'); } diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index ce2c2f708dd47..2cd44d10d6628 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -775,6 +775,7 @@ "--vscode-symbolIcon-typeParameterForeground", "--vscode-symbolIcon-unitForeground", "--vscode-symbolIcon-variableForeground", + "--vscode-strongForeground", "--vscode-tab-activeBackground", "--vscode-tab-activeBorder", "--vscode-tab-activeBorderTop", diff --git a/extensions/copilot/src/extension/tools/vscode-node/toolsService.ts b/extensions/copilot/src/extension/tools/vscode-node/toolsService.ts index ebe31967a7d51..ddccb2b47e6f6 100644 --- a/extensions/copilot/src/extension/tools/vscode-node/toolsService.ts +++ b/extensions/copilot/src/extension/tools/vscode-node/toolsService.ts @@ -175,6 +175,25 @@ export class ToolsService extends BaseToolsService { const startTime = Date.now(); + // Propagate W3C trace context to tool invocations so downstream spans can be + // correlated with this `execute_tool` span. MCP tools forward this onto + // `_meta.traceparent`/`_meta.tracestate` of the JSON-RPC `tools/call` payload + // (MCP SEP-414, see #302301). Only set if not already supplied by the caller. + const optionsWithTrace = options as vscode.LanguageModelToolInvocationOptions & { traceparent?: string; tracestate?: string }; + const ctx = span.getSpanContext(); + if (ctx) { + if (!optionsWithTrace.traceparent) { + // Preserve the upstream W3C trace flags when available. Fall back to `01` + // (sampled) so downstream MCP servers continue to participate in the trace + // when the abstraction does not surface flags (e.g. tests, in-memory impl). + const flags = (ctx.traceFlags ?? 0x01).toString(16).padStart(2, '0'); + optionsWithTrace.traceparent = `00-${ctx.traceId}-${ctx.spanId}-${flags}`; + } + if (!optionsWithTrace.tracestate && ctx.traceState) { + optionsWithTrace.tracestate = ctx.traceState; + } + } + return vscode.lm.invokeTool(getContributedToolName(name), options, token).then( result => { span.setStatus(SpanStatusCode.OK); diff --git a/extensions/copilot/src/platform/otel/common/otelService.ts b/extensions/copilot/src/platform/otel/common/otelService.ts index 1216fb305f3d8..34c3553770625 100644 --- a/extensions/copilot/src/platform/otel/common/otelService.ts +++ b/extensions/copilot/src/platform/otel/common/otelService.ts @@ -15,6 +15,14 @@ export const IOTelService = createServiceIdentifier('IOTelService' export interface TraceContext { readonly traceId: string; readonly spanId: string; + /** + * W3C trace flags from the source span context (e.g. `0x01` for sampled). Optional + * because not all impls preserve it; consumers that build a W3C `traceparent` should + * fall back to a sampled value when unset. + */ + readonly traceFlags?: number; + /** W3C tracestate serialized as a comma-separated key=value list, when present. */ + readonly traceState?: string; } /** diff --git a/extensions/copilot/src/platform/otel/node/otelServiceImpl.ts b/extensions/copilot/src/platform/otel/node/otelServiceImpl.ts index 226f59c8ea779..8d2bda154ff57 100644 --- a/extensions/copilot/src/platform/otel/node/otelServiceImpl.ts +++ b/extensions/copilot/src/platform/otel/node/otelServiceImpl.ts @@ -344,7 +344,7 @@ export class NodeOTelService implements IOTelService { if (!ctx.traceId || !ctx.spanId) { return undefined; } - return { traceId: ctx.traceId, spanId: ctx.spanId }; + return { traceId: ctx.traceId, spanId: ctx.spanId, traceFlags: ctx.traceFlags, traceState: ctx.traceState?.serialize() }; } // ── Trace Context Store ── (for cross-boundary propagation) @@ -620,7 +620,9 @@ class RealSpanHandle implements ISpanHandle { getSpanContext(): TraceContext | undefined { const ctx = this._span.spanContext(); - return ctx.traceId && ctx.spanId ? { traceId: ctx.traceId, spanId: ctx.spanId } : undefined; + return ctx.traceId && ctx.spanId + ? { traceId: ctx.traceId, spanId: ctx.spanId, traceFlags: ctx.traceFlags, traceState: ctx.traceState?.serialize() } + : undefined; } end(): void { diff --git a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.ts b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.ts index 7ef1bfef61f4b..8d0acd345b8b1 100644 --- a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.ts +++ b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; -import { IObservable, ITransaction, ObservablePromise, ObservableResolvedPromise, constObservable, derived, derivedObservableWithWritableCache, mapObservableArrayCached, observableFromValueWithChangeEvent, observableValue, transaction } from '../../../../base/common/observable.js'; +import { IObservable, ITransaction, ObservablePromise, ObservableResolvedPromise, constObservable, derived, derivedObservableWithWritableCache, mapObservableArrayCached, observableFromValueWithChangeEvent, observableValue, transaction, waitForState } from '../../../../base/common/observable.js'; import { timeout } from '../../../../base/common/async.js'; import { URI } from '../../../../base/common/uri.js'; import { ContextKeyValue } from '../../../../platform/contextkey/common/contextkey.js'; @@ -28,6 +28,7 @@ export class MultiDiffEditorViewModel extends Disposable { }); public readonly isLoading; + private readonly _waitForNewDiffs: IObservable[]>>; public readonly items: IObservable; @@ -36,10 +37,12 @@ export class MultiDiffEditorViewModel extends Disposable { (reader, lastValue) => this.focusedDiffItem.read(reader) ?? (lastValue && this.items.read(reader).indexOf(lastValue) !== -1) ? lastValue : undefined ); - public async waitForDiffs(): Promise { - for (const d of this.items.get()) { - await d.diffEditorViewModel.waitForDiff(); + public async waitForDiffOr1s(): Promise { + if (this._documents.get() === 'loading') { + await waitForState(this._documents, documents => documents !== 'loading'); } + + await this._waitForNewDiffs.get().promise; } public collapseAll(): void { @@ -75,7 +78,7 @@ export class MultiDiffEditorViewModel extends Disposable { (d, store) => store.add(RefCounted.create(this._instantiationService.createInstance(DocumentDiffItemViewModel, d, this))) ).recomputeInitiallyAndOnChange(this._store); - const waitForNewDiffs: IObservable[]>> = derived(this, reader => { + this._waitForNewDiffs = derived(this, reader => { const next = allItems.read(reader); const unresolved = next.filter(i => !i.object.waitForInitialDiffOr1s.promiseResult.read(undefined)); if (unresolved.length === 0) { @@ -86,7 +89,7 @@ export class MultiDiffEditorViewModel extends Disposable { ); }); - const resolved = new ObservableResolvedPromise(waitForNewDiffs, [] as readonly RefCounted[], this._store); + const resolved = new ObservableResolvedPromise(this._waitForNewDiffs, [] as readonly RefCounted[], this._store); this.items = derived(this, reader => { const resolvedItems = resolved.lastResolved.read(reader); diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index e55d2c8572bd8..d13343ab852a2 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -124,6 +124,18 @@ export class AgentSideEffects extends Disposable { const agents = this._options.agents.read(reader); this._publishAgentInfos(agents, reader); })); + + // Server-dispatched SessionToolCallComplete actions (e.g. from + // the disconnect timeout in ProtocolServerHandler) bypass + // handleAction, so the agent's SDK deferred never resolves. + // Listen for these envelopes and notify the agent directly. + this._register(this._stateManager.onDidEmitEnvelope(envelope => { + if (!envelope.origin && envelope.action.type === ActionType.SessionToolCallComplete) { + const action = envelope.action; + const agent = this._options.getAgent(action.session); + agent?.onClientToolCallComplete(URI.parse(action.session), action.toolCallId, action.result); + } + })); } /** @@ -622,7 +634,7 @@ export class AgentSideEffects extends Disposable { if (autoApproval !== undefined) { this._toolCallAgents.delete(`${sessionKey}:${e.toolCallId}`); agent.respondToPermissionRequest(e.toolCallId, true); - return; + e = { ...e, confirmationTitle: undefined }; // don't trigger confirmation } this._stateManager.dispatchServerAction( this._permissionManager.createToolReadyAction(e, sessionKey, turnId) diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index 8c6f6aa337424..87b21404fb021 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -31,13 +31,15 @@ import { type ReconnectParams, type IStateSnapshot, } from '../common/state/sessionProtocol.js'; -import { ROOT_STATE_URI, SessionStatus } from '../common/state/sessionState.js'; +import { ResponsePartKind, ROOT_STATE_URI, SessionStatus, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, type SessionState } from '../common/state/sessionState.js'; import type { IProtocolServer, IProtocolTransport } from '../common/state/sessionTransport.js'; import { AgentHostStateManager } from './agentHostStateManager.js'; /** Default capacity of the server-side action replay buffer. */ const REPLAY_BUFFER_CAPACITY = 1000; +const CLIENT_TOOL_CALL_DISCONNECT_TIMEOUT = 30_000; + /** Build a JSON-RPC success response suitable for transport.send(). */ function jsonRpcSuccess(id: number, result: unknown): JsonRpcResponse { return { jsonrpc: '2.0', id, result }; @@ -100,6 +102,7 @@ export class ProtocolServerHandler extends Disposable { private readonly _clients = new Map(); private readonly _replayBuffer: ActionEnvelope[] = []; + private readonly _clientToolCallDisconnectTimeouts = new Map>(); private readonly _onDidChangeConnectionCount = this._register(new Emitter()); @@ -206,6 +209,7 @@ export class ProtocolServerHandler extends Disposable { this._logService.info(`[ProtocolServer] Client disconnected: ${client.clientId}, subscriptions=${client.subscriptions.size}`); this._clients.delete(client.clientId); this._rejectPendingReverseRequests(client.clientId); + this._handleClientDisconnected(client.clientId); this._onDidChangeConnectionCount.fire(this._clients.size); } disposables.dispose(); @@ -256,6 +260,7 @@ export class ProtocolServerHandler extends Disposable { if (snapshot) { snapshots.push(snapshot); client.subscriptions.add(uri.toString()); + this._clearClientToolCallDisconnectTimeout(params.clientId, uri.toString()); } } } @@ -295,6 +300,7 @@ export class ProtocolServerHandler extends Disposable { const actions: ActionEnvelope[] = []; for (const sub of params.subscriptions) { client.subscriptions.add(sub.toString()); + this._clearClientToolCallDisconnectTimeout(params.clientId, sub.toString()); } for (const envelope of this._replayBuffer) { if (envelope.serverSeq > params.lastSeenServerSeq) { @@ -311,12 +317,108 @@ export class ProtocolServerHandler extends Disposable { if (snapshot) { snapshots.push(snapshot); client.subscriptions.add(sub); + this._clearClientToolCallDisconnectTimeout(params.clientId, sub); } } return { client, response: { type: 'snapshot', snapshots } }; } } + private _handleClientDisconnected(clientId: string): void { + for (const session of this._stateManager.getSessionUris()) { + const state = this._stateManager.getSessionState(session); + const ownsPendingToolCall = state ? this._hasPendingClientToolCall(state, clientId) : false; + if (state?.activeClient?.clientId === clientId) { + this._stateManager.dispatchServerAction({ + type: ActionType.SessionActiveClientChanged, + session, + activeClient: null, + }); + } + if (state?.activeClient?.clientId === clientId || ownsPendingToolCall) { + this._startClientToolCallDisconnectTimeout(clientId, session); + } + } + } + + private _hasPendingClientToolCall(state: ReturnType, clientId: string): boolean { + const activeTurn = state?.activeTurn; + if (!activeTurn) { + return false; + } + return activeTurn.responseParts.some(part => part.kind === ResponsePartKind.ToolCall + && part.toolCall.toolClientId === clientId + && (part.toolCall.status === ToolCallStatus.Streaming || part.toolCall.status === ToolCallStatus.Running || part.toolCall.status === ToolCallStatus.PendingConfirmation)); + } + + private _hasReplacementActiveClientTool(state: SessionState, clientId: string, toolName: string): boolean { + const activeClient = state.activeClient; + return activeClient !== undefined + && activeClient.clientId !== clientId + && activeClient.tools.some(tool => tool.name === toolName); + } + + private _startClientToolCallDisconnectTimeout(clientId: string, session: string): void { + this._clearClientToolCallDisconnectTimeout(clientId, session); + const key = this._clientToolCallDisconnectTimeoutKey(clientId, session); + this._clientToolCallDisconnectTimeouts.set(key, setTimeout(() => { + this._clientToolCallDisconnectTimeouts.delete(key); + this._completeDisconnectedClientToolCalls(clientId, session); + }, CLIENT_TOOL_CALL_DISCONNECT_TIMEOUT)); + } + + private _clearClientToolCallDisconnectTimeout(clientId: string, session: string): void { + const key = this._clientToolCallDisconnectTimeoutKey(clientId, session); + const timeout = this._clientToolCallDisconnectTimeouts.get(key); + if (timeout) { + clearTimeout(timeout); + this._clientToolCallDisconnectTimeouts.delete(key); + } + } + + private _clientToolCallDisconnectTimeoutKey(clientId: string, session: string): string { + return `${clientId}\n${session}`; + } + + private _completeDisconnectedClientToolCalls(clientId: string, session: string): void { + const state = this._stateManager.getSessionState(session); + const activeTurn = state?.activeTurn; + if (!activeTurn) { + return; + } + for (const part of activeTurn.responseParts) { + if (part.kind !== ResponsePartKind.ToolCall) { + continue; + } + const toolCall = part.toolCall; + if (toolCall.toolClientId === clientId && (toolCall.status === ToolCallStatus.Streaming || toolCall.status === ToolCallStatus.Running || toolCall.status === ToolCallStatus.PendingConfirmation)) { + const mayRetryWithReplacementClient = this._hasReplacementActiveClientTool(state, clientId, toolCall.toolName); + if (toolCall.status === ToolCallStatus.Streaming) { + this._stateManager.dispatchServerAction({ + type: ActionType.SessionToolCallReady, + session, + turnId: activeTurn.id, + toolCallId: toolCall.toolCallId, + invocationMessage: toolCall.invocationMessage ?? toolCall.displayName, + confirmed: ToolCallConfirmationReason.NotNeeded, + }); + } + this._stateManager.dispatchServerAction({ + type: ActionType.SessionToolCallComplete, + session, + turnId: activeTurn.id, + toolCallId: toolCall.toolCallId, + result: { + success: false, + pastTenseMessage: `${toolCall.displayName} failed`, + ...(mayRetryWithReplacementClient ? { content: [{ type: ToolResultContentType.Text, text: `The client that was running ${toolCall.displayName} disconnected, but another active client now provides ${toolCall.displayName}. You may try calling the tool again.` }] } : {}), + error: { message: `Client ${clientId} disconnected before completing ${toolCall.displayName}` }, + }, + }); + } + } + } + // ---- Requests (expect a response) --------------------------------------- /** @@ -328,6 +430,7 @@ export class ProtocolServerHandler extends Disposable { try { const snapshot = await this._agentService.subscribe(URI.parse(params.resource)); client.subscriptions.add(params.resource); + this._clearClientToolCallDisconnectTimeout(client.clientId, params.resource); return { snapshot }; } catch (err) { if (err instanceof ProtocolError) { @@ -606,6 +709,10 @@ export class ProtocolServerHandler extends Disposable { pending.reject(new Error('ProtocolServerHandler disposed')); } this._pendingReverseRequests.clear(); + for (const timeout of this._clientToolCallDisconnectTimeouts.values()) { + clearTimeout(timeout); + } + this._clientToolCallDisconnectTimeouts.clear(); this._replayBuffer.length = 0; super.dispose(); } diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index 5c45cf83f80b1..217fa25a24085 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -961,6 +961,82 @@ suite('AgentSideEffects', () => { }); }); + // ---- tool_ready progress dispatch ----------------------------------- + + suite('tool_ready dispatches progress actions to advance tool call state', () => { + + test('tool_ready for a non-permission tool dispatches SessionToolCallReady and advances state from Streaming to Running', () => { + setupSession(); + startTurn('turn-1'); + disposables.add(sideEffects.registerProgressListener(agent)); + + // tool_start puts the tool call into Streaming state + agent.fireProgress({ + session: sessionUri, + type: 'tool_start', + toolCallId: 'tc-ready-1', + toolName: 'runTask', + displayName: 'Run Task', + invocationMessage: 'Running task...', + toolClientId: 'test-client', + }); + + const stateAfterStart = stateManager.getSessionState(sessionUri.toString()); + const partAfterStart = stateAfterStart?.activeTurn?.responseParts[0]; + assert.strictEqual(partAfterStart?.kind, ResponsePartKind.ToolCall); + assert.strictEqual(partAfterStart?.kind === ResponsePartKind.ToolCall ? partAfterStart.toolCall.status : undefined, ToolCallStatus.Streaming); + + // tool_ready without confirmationTitle should dispatch the ready + // action and advance the tool call to Running + agent.fireProgress({ + session: sessionUri, + type: 'tool_ready', + toolCallId: 'tc-ready-1', + invocationMessage: 'Run Task', + toolInput: '{"task":"build"}', + }); + + const stateAfterReady = stateManager.getSessionState(sessionUri.toString()); + const partAfterReady = stateAfterReady?.activeTurn?.responseParts[0]; + assert.strictEqual(partAfterReady?.kind, ResponsePartKind.ToolCall); + assert.strictEqual(partAfterReady?.kind === ResponsePartKind.ToolCall ? partAfterReady.toolCall.status : undefined, ToolCallStatus.Running, + 'tool call should advance from Streaming to Running after tool_ready'); + }); + + test('tool_ready for a permission-gated tool dispatches SessionToolCallReady and advances state to PendingConfirmation', () => { + setupSession(); + startTurn('turn-1'); + disposables.add(sideEffects.registerProgressListener(agent)); + + agent.fireProgress({ + session: sessionUri, + type: 'tool_start', + toolCallId: 'tc-perm-1', + toolName: 'write', + displayName: 'Write File', + invocationMessage: 'Writing file...', + toolClientId: 'test-client', + }); + + // tool_ready with confirmationTitle should dispatch the ready + // action and advance the tool call to PendingConfirmation + agent.fireProgress({ + session: sessionUri, + type: 'tool_ready', + toolCallId: 'tc-perm-1', + invocationMessage: 'Write .env', + confirmationTitle: 'Write .env', + toolInput: '{"path":".env"}', + }); + + const state = stateManager.getSessionState(sessionUri.toString()); + const part = state?.activeTurn?.responseParts[0]; + assert.strictEqual(part?.kind, ResponsePartKind.ToolCall); + assert.strictEqual(part?.kind === ResponsePartKind.ToolCall ? part.toolCall.status : undefined, ToolCallStatus.PendingConfirmation, + 'tool call should advance to PendingConfirmation for permission-gated tool_ready'); + }); + }); + // ---- Session-level auto-approve (config) ---------------------------- suite('session config auto-approve', () => { diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index 3583f69d1ab00..948533c528e33 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -83,12 +83,12 @@ class CapturingLogService extends NullLogService { * {@link ToolResultObject} — which is what {@link CopilotAgentSession}'s * handler implementation actually returns. */ -function invokeClientToolHandler(tool: Pick, toolCallId: string): Promise { - return Promise.resolve(tool.handler({}, { +function invokeClientToolHandler(tool: Pick, toolCallId: string, args: Record = {}): Promise { + return Promise.resolve(tool.handler(args, { sessionId: 'test-session-1', toolCallId, toolName: tool.name, - arguments: {}, + arguments: args, })) as Promise; } @@ -940,7 +940,7 @@ suite('CopilotAgentSession', () => { plugins: [], }; - test('tool_start fires immediately for client tools', async () => { + test('client tool handler waits for completion without emitting tool_ready', async () => { const { session, mockSession, progressEvents } = await createAgentSession(disposables, { clientSnapshot: snapshot }); // SDK emits tool.execution_start — tool_start fires immediately @@ -956,9 +956,13 @@ suite('CopilotAgentSession', () => { assert.strictEqual(progressEvents[0].toolClientId, 'test-client'); } - // SDK invokes the handler + // SDK invokes the handler — it creates a deferred and waits, + // but does NOT fire tool_ready (that comes from the permission flow). const tools = session.createClientSdkTools(); - const handlerPromise = invokeClientToolHandler(tools[0], 'tc-client-1'); + const handlerPromise = invokeClientToolHandler(tools[0], 'tc-client-1', { file: 'test.ts' }); + + // No tool_ready should have been emitted by the handler + assert.strictEqual(progressEvents.filter(e => e.type === 'tool_ready').length, 0); // Complete the tool call session.handleClientToolCallComplete('tc-client-1', { @@ -972,7 +976,7 @@ suite('CopilotAgentSession', () => { assert.strictEqual(result.textResultForLlm, 'result text'); }); - test('permission request consumes pending auto-ready for client tools', async () => { + test('client tool handler does not emit tool_ready (permission flow owns it)', async () => { const { session, mockSession, progressEvents, waitForProgress } = await createAgentSession(disposables, { clientSnapshot: snapshot }); // SDK emits tool.execution_start — tool_start fires immediately @@ -986,8 +990,7 @@ suite('CopilotAgentSession', () => { assert.strictEqual(progressEvents.filter(e => e.type === 'tool_start').length, 1); assert.strictEqual(progressEvents.filter(e => e.type === 'tool_ready').length, 0); - // Permission request fires — tool_ready from permission flow - // (with confirmationTitle) replaces the auto-ready + // Permission request fires — tool_ready from permission flow. const resultPromise = session.handlePermissionRequest({ kind: 'custom-tool', toolCallId: 'tc-client-perm', @@ -996,17 +999,29 @@ suite('CopilotAgentSession', () => { // tool_ready from permission flow should have fired (with confirmationTitle) await waitForProgress(e => e.type === 'tool_ready'); - const toolReadys = progressEvents.filter(e => e.type === 'tool_ready'); - assert.strictEqual(toolReadys.length, 1); - if (toolReadys[0].type === 'tool_ready') { - assert.strictEqual(toolReadys[0].toolCallId, 'tc-client-perm'); - assert.ok(toolReadys[0].confirmationTitle); + const permissionReady = progressEvents.filter(e => e.type === 'tool_ready'); + assert.strictEqual(permissionReady.length, 1); + if (permissionReady[0].type === 'tool_ready') { + assert.strictEqual(permissionReady[0].toolCallId, 'tc-client-perm'); + assert.ok(permissionReady[0].confirmationTitle); } + const tools = session.createClientSdkTools(); + const handlerPromise = invokeClientToolHandler(tools[0], 'tc-client-perm'); + + // The handler should NOT emit its own tool_ready — only the + // permission flow fires tool_ready for client tools. + assert.strictEqual(progressEvents.filter(e => e.type === 'tool_ready').length, 1, 'handler should not emit a second tool_ready'); + // Approve and clean up session.respondToPermissionRequest('tc-client-perm', true); const permResult = await resultPromise; assert.strictEqual(permResult.kind, 'approved'); + session.handleClientToolCallComplete('tc-client-perm', { + success: true, + pastTenseMessage: 'did it', + }); + await handlerPromise; }); test('handleClientToolCallComplete pre-completes when no handler is waiting yet', async () => { @@ -1120,7 +1135,7 @@ suite('CopilotAgentSession', () => { assert.strictEqual(entry.args[0], '[Copilot:test-session-1] Failed in client tool handler: tool=my_tool, toolCallId=tc-client-error'); }); - test('tool_start stores pending auto-ready data for client tools', async () => { + test('permission request before client tool handler emits only confirmation ready', async () => { const { session, mockSession, progressEvents, waitForProgress } = await createAgentSession(disposables, { clientSnapshot: snapshot }); mockSession.fire('tool.execution_start', { @@ -1132,11 +1147,8 @@ suite('CopilotAgentSession', () => { // tool_start should have fired assert.strictEqual(progressEvents.filter(e => e.type === 'tool_start').length, 1); - // The session should have stored pending auto-ready data. - // We verify this indirectly: if we now fire a permission request - // for the same toolCallId, the pending auto-ready is consumed - // (tested by the permission request test above), and we get - // tool_ready with confirmationTitle instead. + // Permission before the handler should produce only the confirmation + // tool_ready, not a synthetic auto-ready. const resultPromise = session.handlePermissionRequest({ kind: 'custom-tool', toolCallId: 'tc-ready-data', diff --git a/src/vs/platform/agentHost/test/node/mockAgent.ts b/src/vs/platform/agentHost/test/node/mockAgent.ts index da9ff4ad66cb3..420c89725f01c 100644 --- a/src/vs/platform/agentHost/test/node/mockAgent.ts +++ b/src/vs/platform/agentHost/test/node/mockAgent.ts @@ -464,8 +464,10 @@ export class ScriptedMockAgent implements IAgent { } case 'client-tool': { - // Fires tool_start with toolClientId to simulate a client-provided tool. - // The server waits for the client to dispatch toolCallComplete. + // Fires tool_start with toolClientId followed by tool_ready + // (without confirmationTitle) to simulate a client-provided tool + // that is ready for execution. The real SDK handler fires + // tool_ready once its deferred is in place. (async () => { await timeout(10); this._onDidSessionProgress.fire({ @@ -477,6 +479,14 @@ export class ScriptedMockAgent implements IAgent { invocationMessage: 'Running tests...', toolClientId: 'test-client-tool', }); + await timeout(5); + this._onDidSessionProgress.fire({ + type: 'tool_ready', + session, + toolCallId: 'tc-client-1', + invocationMessage: 'Running tests...', + toolInput: '{}', + }); })(); // The tool stays pending — the client is responsible for dispatching toolCallComplete. // Once complete, fire a response delta and idle. @@ -627,7 +637,14 @@ export class ScriptedMockAgent implements IAgent { setClientTools(): void { } + private didCompleteToolCalls = new Set(); + onClientToolCallComplete(session: URI, toolCallId: string, result: ToolCallResult): void { + const key = `${session.toString()}:${toolCallId}`; + if (this.didCompleteToolCalls.has(key)) { + return; + } + this.didCompleteToolCalls.add(key); // Fire tool_complete and resolve any pending callback. this._onDidSessionProgress.fire({ type: 'tool_complete', diff --git a/src/vs/platform/agentHost/test/node/protocol/clientTools.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/clientTools.integrationTest.ts index 08a424aba7925..2849e35f563cc 100644 --- a/src/vs/platform/agentHost/test/node/protocol/clientTools.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/clientTools.integrationTest.ts @@ -54,16 +54,17 @@ suite('Protocol WebSocket — Client Tools', function () { // ---- Client tool: tool_start with toolClientId -------------------------- - test('client tool_start emits only toolCallStart (no auto-ready)', async function () { + test('client tool_start emits toolCallStart then toolCallReady (auto-confirmed)', async function () { this.timeout(10_000); const sessionUri = await createAndSubscribeSession(client, 'test-client-tool'); dispatchTurnStarted(client, sessionUri, 'turn-ct', 'client-tool', 1); // Wait for toolCallStart - const toolStartNotif = await client.waitForNotification( - n => isActionNotification(n, 'session/toolCallStart'), - ); + const [toolStartNotif, toolReadyNotif] = await Promise.all([ + client.waitForNotification(n => isActionNotification(n, 'session/toolCallStart')), + client.waitForNotification(n => isActionNotification(n, 'session/toolCallReady')), + ]); const toolStartAction = getActionEnvelope(toolStartNotif).action as { toolCallId: string; toolClientId?: string; @@ -71,12 +72,12 @@ suite('Protocol WebSocket — Client Tools', function () { assert.strictEqual(toolStartAction.toolCallId, 'tc-client-1'); assert.strictEqual(toolStartAction.toolClientId, 'test-client-tool'); - // Verify that no auto-ready was emitted alongside the toolCallStart. - // The client tool flow should NOT fire an immediate toolCallReady. - const autoReadyNotifs = client.receivedNotifications( - n => isActionNotification(n, 'session/toolCallReady'), - ); - assert.strictEqual(autoReadyNotifs.length, 0, 'should not have auto-ready for client tools'); + const toolReadyAction = getActionEnvelope(toolReadyNotif).action as { + toolCallId: string; + confirmed?: string; + }; + assert.strictEqual(toolReadyAction.toolCallId, 'tc-client-1'); + assert.strictEqual(toolReadyAction.confirmed, 'not-needed'); // Complete the client tool call client.notify('dispatchAction', { diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index 67ec63395164a..b0807afae93ea 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -7,6 +7,7 @@ import assert from 'assert'; import { Emitter, Event } from '../../../../base/common/event.js'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; +import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../log/common/log.js'; import { type IAgentCreateSessionConfig, type IAgentResolveSessionConfigParams, type IAgentService, type IAgentSessionConfigCompletionsParams, type IAgentSessionMetadata, type AuthenticateParams, type AuthenticateResult } from '../../common/agentService.js'; @@ -14,7 +15,7 @@ import { ListSessionsResult, ResourceReadResult, ResolveSessionConfigResult, Ses import { ActionType, type IRootConfigChangedAction, type SessionAction, type TerminalAction } from '../../common/state/sessionActions.js'; import { PROTOCOL_VERSION } from '../../common/state/sessionCapabilities.js'; import { isJsonRpcNotification, isJsonRpcResponse, JSON_RPC_INTERNAL_ERROR, ProtocolError, type AhpNotification, type InitializeResult, type ProtocolMessage, type ReconnectResult, type ResourceListResult, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../../common/state/sessionProtocol.js'; -import { SessionStatus, type SessionSummary } from '../../common/state/sessionState.js'; +import { ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, type SessionSummary } from '../../common/state/sessionState.js'; import type { IProtocolServer, IProtocolTransport } from '../../common/state/sessionTransport.js'; import { ProtocolServerHandler } from '../../node/protocolServerHandler.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; @@ -504,6 +505,300 @@ suite('ProtocolServerHandler', () => { assert.strictEqual(transport.sent.length, 0); }); + test('client disconnect clears active client and fails owned tool calls after grace period', () => { + return runWithFakedTimers({ useFakeTimers: true }, async () => { + stateManager.createSession(makeSessionSummary()); + stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); + stateManager.dispatchServerAction({ + type: ActionType.SessionActiveClientChanged, + session: sessionUri, + activeClient: { + clientId: 'client-tools', + tools: [{ name: 'runTask', description: 'Runs a task' }], + }, + }); + stateManager.dispatchServerAction({ + type: ActionType.SessionTurnStarted, + session: sessionUri, + turnId: 'turn-1', + userMessage: { text: 'run it' }, + }); + stateManager.dispatchServerAction({ + type: ActionType.SessionToolCallStart, + session: sessionUri, + turnId: 'turn-1', + toolCallId: 'tool-1', + toolName: 'runTask', + displayName: 'Run Task', + toolClientId: 'client-tools', + }); + stateManager.dispatchServerAction({ + type: ActionType.SessionToolCallReady, + session: sessionUri, + turnId: 'turn-1', + toolCallId: 'tool-1', + invocationMessage: 'Run Task', + toolInput: '{}', + confirmed: ToolCallConfirmationReason.NotNeeded, + }); + + const transport = connectClient('client-tools', [sessionUri]); + transport.simulateClose(); + + assert.strictEqual(stateManager.getSessionState(sessionUri)?.activeClient, undefined); + let part = stateManager.getSessionState(sessionUri)?.activeTurn?.responseParts[0]; + assert.strictEqual(part?.kind, ResponsePartKind.ToolCall); + assert.strictEqual(part?.kind === ResponsePartKind.ToolCall ? part.toolCall.status : undefined, ToolCallStatus.Running); + + await new Promise(r => setTimeout(r, 30_001)); + + part = stateManager.getSessionState(sessionUri)?.activeTurn?.responseParts[0]; + assert.strictEqual(part?.kind, ResponsePartKind.ToolCall); + assert.deepStrictEqual(part?.kind === ResponsePartKind.ToolCall ? { + status: part.toolCall.status, + success: part.toolCall.status === ToolCallStatus.Completed ? part.toolCall.success : undefined, + error: part.toolCall.status === ToolCallStatus.Completed ? part.toolCall.error?.message : undefined, + } : undefined, { + status: ToolCallStatus.Completed, + success: false, + error: 'Client client-tools disconnected before completing Run Task', + }); + }); + }); + + test('client disconnect fails owned streaming tool calls after grace period', () => { + return runWithFakedTimers({ useFakeTimers: true }, async () => { + stateManager.createSession(makeSessionSummary()); + stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); + stateManager.dispatchServerAction({ + type: ActionType.SessionActiveClientChanged, + session: sessionUri, + activeClient: { + clientId: 'client-tools', + tools: [{ name: 'runTask', description: 'Runs a task' }], + }, + }); + stateManager.dispatchServerAction({ + type: ActionType.SessionTurnStarted, + session: sessionUri, + turnId: 'turn-1', + userMessage: { text: 'run it' }, + }); + stateManager.dispatchServerAction({ + type: ActionType.SessionToolCallStart, + session: sessionUri, + turnId: 'turn-1', + toolCallId: 'tool-1', + toolName: 'runTask', + displayName: 'Run Task', + toolClientId: 'client-tools', + }); + + const transport = connectClient('client-tools', [sessionUri]); + transport.simulateClose(); + + let part = stateManager.getSessionState(sessionUri)?.activeTurn?.responseParts[0]; + assert.strictEqual(part?.kind, ResponsePartKind.ToolCall); + assert.strictEqual(part?.kind === ResponsePartKind.ToolCall ? part.toolCall.status : undefined, ToolCallStatus.Streaming); + + await new Promise(r => setTimeout(r, 30_001)); + + part = stateManager.getSessionState(sessionUri)?.activeTurn?.responseParts[0]; + assert.strictEqual(part?.kind, ResponsePartKind.ToolCall); + assert.deepStrictEqual(part?.kind === ResponsePartKind.ToolCall ? { + status: part.toolCall.status, + success: part.toolCall.status === ToolCallStatus.Completed ? part.toolCall.success : undefined, + error: part.toolCall.status === ToolCallStatus.Completed ? part.toolCall.error?.message : undefined, + } : undefined, { + status: ToolCallStatus.Completed, + success: false, + error: 'Client client-tools disconnected before completing Run Task', + }); + }); + }); + + test('client reconnect without session subscription does not clear tool call disconnect timeout', () => { + return runWithFakedTimers({ useFakeTimers: true }, async () => { + stateManager.createSession(makeSessionSummary()); + stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); + stateManager.dispatchServerAction({ + type: ActionType.SessionActiveClientChanged, + session: sessionUri, + activeClient: { + clientId: 'client-tools', + tools: [{ name: 'runTask', description: 'Runs a task' }], + }, + }); + stateManager.dispatchServerAction({ + type: ActionType.SessionTurnStarted, + session: sessionUri, + turnId: 'turn-1', + userMessage: { text: 'run it' }, + }); + stateManager.dispatchServerAction({ + type: ActionType.SessionToolCallStart, + session: sessionUri, + turnId: 'turn-1', + toolCallId: 'tool-1', + toolName: 'runTask', + displayName: 'Run Task', + toolClientId: 'client-tools', + }); + stateManager.dispatchServerAction({ + type: ActionType.SessionToolCallReady, + session: sessionUri, + turnId: 'turn-1', + toolCallId: 'tool-1', + invocationMessage: 'Run Task', + toolInput: '{}', + confirmed: ToolCallConfirmationReason.NotNeeded, + }); + + const transport = connectClient('client-tools', [sessionUri]); + transport.simulateClose(); + + const reconnectTransport = new MockProtocolTransport(); + server.simulateConnection(reconnectTransport); + reconnectTransport.simulateMessage(request(1, 'reconnect', { + clientId: 'client-tools', + lastSeenServerSeq: stateManager.serverSeq, + subscriptions: [], + })); + + await new Promise(r => setTimeout(r, 30_001)); + + const part = stateManager.getSessionState(sessionUri)?.activeTurn?.responseParts[0]; + assert.strictEqual(part?.kind, ResponsePartKind.ToolCall); + assert.deepStrictEqual(part?.kind === ResponsePartKind.ToolCall ? { + status: part.toolCall.status, + success: part.toolCall.status === ToolCallStatus.Completed ? part.toolCall.success : undefined, + } : undefined, { + status: ToolCallStatus.Completed, + success: false, + }); + }); + }); + + test('client reconnect with session subscription clears tool call disconnect timeout for that session', () => { + return runWithFakedTimers({ useFakeTimers: true }, async () => { + stateManager.createSession(makeSessionSummary()); + stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); + stateManager.dispatchServerAction({ + type: ActionType.SessionActiveClientChanged, + session: sessionUri, + activeClient: { + clientId: 'client-tools', + tools: [{ name: 'runTask', description: 'Runs a task' }], + }, + }); + stateManager.dispatchServerAction({ + type: ActionType.SessionTurnStarted, + session: sessionUri, + turnId: 'turn-1', + userMessage: { text: 'run it' }, + }); + stateManager.dispatchServerAction({ + type: ActionType.SessionToolCallStart, + session: sessionUri, + turnId: 'turn-1', + toolCallId: 'tool-1', + toolName: 'runTask', + displayName: 'Run Task', + toolClientId: 'client-tools', + }); + stateManager.dispatchServerAction({ + type: ActionType.SessionToolCallReady, + session: sessionUri, + turnId: 'turn-1', + toolCallId: 'tool-1', + invocationMessage: 'Run Task', + toolInput: '{}', + confirmed: ToolCallConfirmationReason.NotNeeded, + }); + + const transport = connectClient('client-tools', [sessionUri]); + transport.simulateClose(); + + const reconnectTransport = new MockProtocolTransport(); + server.simulateConnection(reconnectTransport); + reconnectTransport.simulateMessage(request(1, 'reconnect', { + clientId: 'client-tools', + lastSeenServerSeq: stateManager.serverSeq, + subscriptions: [sessionUri], + })); + + await new Promise(r => setTimeout(r, 30_001)); + + const part = stateManager.getSessionState(sessionUri)?.activeTurn?.responseParts[0]; + assert.strictEqual(part?.kind, ResponsePartKind.ToolCall); + assert.strictEqual(part?.kind === ResponsePartKind.ToolCall ? part.toolCall.status : undefined, ToolCallStatus.Running); + }); + }); + + test('client tool timeout tells model it may retry when replacement active client provides the tool', () => { + return runWithFakedTimers({ useFakeTimers: true }, async () => { + stateManager.createSession(makeSessionSummary()); + stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); + stateManager.dispatchServerAction({ + type: ActionType.SessionActiveClientChanged, + session: sessionUri, + activeClient: { + clientId: 'client-tools', + tools: [{ name: 'runTask', description: 'Runs a task' }], + }, + }); + stateManager.dispatchServerAction({ + type: ActionType.SessionTurnStarted, + session: sessionUri, + turnId: 'turn-1', + userMessage: { text: 'run it' }, + }); + stateManager.dispatchServerAction({ + type: ActionType.SessionToolCallStart, + session: sessionUri, + turnId: 'turn-1', + toolCallId: 'tool-1', + toolName: 'runTask', + displayName: 'Run Task', + toolClientId: 'client-tools', + }); + stateManager.dispatchServerAction({ + type: ActionType.SessionToolCallReady, + session: sessionUri, + turnId: 'turn-1', + toolCallId: 'tool-1', + invocationMessage: 'Run Task', + toolInput: '{}', + confirmed: ToolCallConfirmationReason.NotNeeded, + }); + + const transport = connectClient('client-tools', [sessionUri]); + transport.simulateClose(); + stateManager.dispatchServerAction({ + type: ActionType.SessionActiveClientChanged, + session: sessionUri, + activeClient: { + clientId: 'client-replacement', + tools: [{ name: 'runTask', description: 'Runs a task' }], + }, + }); + + await new Promise(r => setTimeout(r, 30_001)); + + const part = stateManager.getSessionState(sessionUri)?.activeTurn?.responseParts[0]; + assert.strictEqual(part?.kind, ResponsePartKind.ToolCall); + assert.deepStrictEqual(part?.kind === ResponsePartKind.ToolCall && part.toolCall.status === ToolCallStatus.Completed ? { + status: part.toolCall.status, + success: part.toolCall.success, + content: part.toolCall.content, + } : undefined, { + status: ToolCallStatus.Completed, + success: false, + content: [{ type: ToolResultContentType.Text, text: 'The client that was running Run Task disconnected, but another active client now provides Run Task. You may try calling the tool again.' }], + }); + }); + }); + test('handshake includes defaultDirectory from side effects', () => { const transport = connectClient('client-home'); diff --git a/src/vs/platform/theme/common/colors/baseColors.ts b/src/vs/platform/theme/common/colors/baseColors.ts index a3e85f9e88e06..645b49bb4521a 100644 --- a/src/vs/platform/theme/common/colors/baseColors.ts +++ b/src/vs/platform/theme/common/colors/baseColors.ts @@ -14,6 +14,10 @@ export const foreground = registerColor('foreground', { dark: '#CCCCCC', light: '#616161', hcDark: '#FFFFFF', hcLight: '#292929' }, nls.localize('foreground', "Overall foreground color. This color is only used if not overridden by a component.")); +export const strongForeground = registerColor('strongForeground', + { dark: '#FFFFFF', light: '#000000', hcDark: '#FFFFFF', hcLight: '#000000' }, + nls.localize('strongForeground', "Highest-contrast foreground color, intended for text or icons that need maximum legibility across various backgrounds. This color is only used if not overridden by a component.")); + export const disabledForeground = registerColor('disabledForeground', { dark: '#CCCCCC80', light: '#61616180', hcDark: '#A5A5A5', hcLight: '#7F7F7F' }, nls.localize('disabledForeground', "Overall foreground for disabled elements. This color is only used if not overridden by a component.")); diff --git a/src/vs/sessions/browser/media/style.css b/src/vs/sessions/browser/media/style.css index 1dddca7ec3411..5c97242d42c2f 100644 --- a/src/vs/sessions/browser/media/style.css +++ b/src/vs/sessions/browser/media/style.css @@ -269,6 +269,31 @@ --tab-border-top-color: transparent !important; } +/* Allow tabs to shrink in narrow viewports so the close button stays reachable. + * The default `sizing-fit` rule sets `min-width: fit-content; flex-shrink: 0;` + * which prevents the tab from shrinking below its label width and pushes the + * close button out of view. */ +.agent-sessions-workbench .part.editor .tabs-container > .tab.sizing-fit { + min-width: 0 !important; + flex-shrink: 1 !important; +} + +.agent-sessions-workbench .part.editor .tabs-container > .tab.sizing-fit .monaco-icon-label, +.agent-sessions-workbench .part.editor .tabs-container > .tab.sizing-fit .monaco-icon-label > .monaco-icon-label-container { + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +/* Keep the close button reserved within the tab so it remains accessible when + * the tab shrinks. Without this the action sits in an `overflow: hidden` + * container that only reveals on hover, which combined with the smaller tab + * makes the close target hard to hit. */ +.agent-sessions-workbench .part.editor .tabs-container > .tab > .tab-actions { + flex: 0 0 auto; + overflow: visible; +} + .agent-sessions-workbench .part.editor .tabs-and-actions-container { --tabs-border-bottom-color: transparent !important; align-items: center; diff --git a/src/vs/sessions/browser/parts/media/editorPart.css b/src/vs/sessions/browser/parts/media/editorPart.css index feb1ff1b7cf2f..978d095f7c061 100644 --- a/src/vs/sessions/browser/parts/media/editorPart.css +++ b/src/vs/sessions/browser/parts/media/editorPart.css @@ -40,7 +40,7 @@ display: block; cursor: default; flex: initial; - padding: 0 8px 0 4px; + padding: 0 0 0 4px; height: var(--editor-group-tab-height); } diff --git a/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts index e8ce244b5c859..afb0167d7f3ca 100644 --- a/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts +++ b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts @@ -220,6 +220,17 @@ registerAction2(class extends Action2 { } }); +// Color Theme (hidden on phone — no theme picker UI on mobile) +MenuRegistry.appendMenuItem(AccountMenu, { + command: { + id: 'workbench.action.selectTheme', + title: localize('selectColorTheme', "Color Theme"), + }, + when: IsPhoneLayoutContext.negate(), + group: '2_settings', + order: 1, +}); + // Settings (hidden on phone — no settings UI on mobile) MenuRegistry.appendMenuItem(AccountMenu, { command: { @@ -228,7 +239,7 @@ MenuRegistry.appendMenuItem(AccountMenu, { }, when: IsPhoneLayoutContext.negate(), group: '2_settings', - order: 1, + order: 2, }); // Update actions @@ -596,10 +607,11 @@ class TitleBarAccountWidget extends BaseActionViewItem { fillInActionBarActions(menu.getActions(), rawActions); menu.dispose(); + const themeAction = rawActions.find(action => !(action instanceof Separator) && action.id === 'workbench.action.selectTheme'); const settingsAction = rawActions.find(action => !(action instanceof Separator) && action.id === 'workbench.action.openSettings'); const signOutAction = rawActions.find(action => !(action instanceof Separator) && action.id === 'workbench.action.agenticSignOut'); - return [settingsAction, signOutAction].filter((action): action is IAction => !!action); + return [themeAction, settingsAction, signOutAction].filter((action): action is IAction => !!action); } private getPanelActions(): IAction[] { @@ -620,12 +632,15 @@ class TitleBarAccountWidget extends BaseActionViewItem { return action.id !== 'workbench.action.agenticSignOut' && action.id !== 'workbench.action.openSettings' + && action.id !== 'workbench.action.selectTheme' && !action.id.startsWith('update.'); }); } private getHeaderActionIcon(action: IAction): ThemeIcon { switch (action.id) { + case 'workbench.action.selectTheme': + return Codicon.symbolColor; case 'workbench.action.openSettings': return Codicon.settingsGear; case 'workbench.action.agenticSignOut': diff --git a/src/vs/sessions/contrib/accountMenu/browser/media/accountTitleBarWidget.css b/src/vs/sessions/contrib/accountMenu/browser/media/accountTitleBarWidget.css index 57b09d4d21134..efedb5b7a4fe9 100644 --- a/src/vs/sessions/contrib/accountMenu/browser/media/accountTitleBarWidget.css +++ b/src/vs/sessions/contrib/accountMenu/browser/media/accountTitleBarWidget.css @@ -272,7 +272,6 @@ display: flex; flex-direction: column; min-height: 0; - padding-top: 4px; border-top: 1px solid var(--vscode-menu-separatorBackground, var(--vscode-disabledForeground)); } @@ -287,7 +286,7 @@ display: flex; flex-direction: column; gap: 0; - padding: 4px 0; + padding: 2px 0; } .agent-sessions-workbench .sessions-account-titlebar-panel-separator { diff --git a/src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css b/src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css index 99c43c044da9b..6f8c1d2bc36c6 100644 --- a/src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css +++ b/src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css @@ -120,6 +120,37 @@ min-width: 0; } +/* Chat status dashboard embedded in the agents-app titlebar account panel */ +.sessions-account-titlebar-panel-content .chat-status-bar-entry-tooltip { + max-width: 360px; + padding: 2px 0 4px 0; +} + +.sessions-account-titlebar-panel-content .chat-status-bar-entry-tooltip div.header { + padding: 8px 10px 8px 8px; +} + +.sessions-account-titlebar-panel-content .chat-status-bar-entry-tooltip .quota-indicator { + margin-bottom: 8px; + padding: 0 8px; +} + +.sessions-account-titlebar-panel-content .chat-status-bar-entry-tooltip .collapsible-header { + padding: 8px 10px 8px 8px; +} + +.sessions-account-titlebar-panel-content .chat-status-bar-entry-tooltip .collapsible-inner { + padding-top: 0; +} + +.sessions-account-titlebar-panel-content .chat-status-bar-entry-tooltip .contribution .header { + padding: 0 10px 8px 8px; +} + +.sessions-account-titlebar-panel-content .chat-status-bar-entry-tooltip .contribution .body { + padding: 0 10px 4px 8px; +} + .monaco-workbench .part.sidebar > .sidebar-footer .account-widget-update .account-widget-update-button { width: auto; max-width: none; diff --git a/src/vs/workbench/api/common/extHostLanguageModelTools.ts b/src/vs/workbench/api/common/extHostLanguageModelTools.ts index f10129cfb766f..7c224277f99fc 100644 --- a/src/vs/workbench/api/common/extHostLanguageModelTools.ts +++ b/src/vs/workbench/api/common/extHostLanguageModelTools.ts @@ -130,6 +130,8 @@ export class ExtHostLanguageModelTools implements ExtHostLanguageModelToolsShape subAgentInvocationId: isProposedApiEnabled(extension, 'chatParticipantPrivate') ? options.subAgentInvocationId : undefined, chatStreamToolCallId: isProposedApiEnabled(extension, 'chatParticipantAdditions') ? options.chatStreamToolCallId : undefined, preToolUseResult: isProposedApiEnabled(extension, 'chatParticipantPrivate') ? options.preToolUseResult : undefined, + traceparent: isProposedApiEnabled(extension, 'chatParticipantPrivate') ? options.traceparent : undefined, + tracestate: isProposedApiEnabled(extension, 'chatParticipantPrivate') ? options.tracestate : undefined, }, token); const dto: Dto = result instanceof SerializableObjectWithBuffers ? result.value : result; @@ -191,6 +193,8 @@ export class ExtHostLanguageModelTools implements ExtHostLanguageModelToolsShape options.chatInteractionId = dto.chatInteractionId; options.chatSessionResource = URI.revive(dto.context?.sessionResource); options.subAgentInvocationId = dto.subAgentInvocationId; + options.traceparent = dto.traceparent; + options.tracestate = dto.tracestate; } if (isProposedApiEnabled(item.extension, 'chatParticipantAdditions') && dto.modelId) { diff --git a/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts b/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts index 76a523f257e8c..d03b23148a500 100644 --- a/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts +++ b/src/vs/workbench/browser/parts/titlebar/titlebarPart.ts @@ -698,13 +698,17 @@ export class BrowserTitlebarPart extends Part implements ITitlebarPart { } } - // --- Leading Global Actions (rendered before layout controls; opt-in via TitleBarLeadingActionsGroup) + // --- Leading Global Actions (rendered before layout controls; opt-in via TitleBarLeadingActionsGroup). + // Use a scratch bucket so non-leading actions don't leak into the shared `secondary` (overflow) list here; + // they are added by the trailing global-actions pass below. if (this.globalToolbarMenu) { + const leading: IToolbarActions = { primary: [], secondary: [] }; fillInActionBarActions( this.globalToolbarMenu.getActions(), - actions, + leading, actionGroup => actionGroup === TitleBarLeadingActionsGroup ); + actions.primary.push(...leading.primary); } // --- Layout Actions @@ -716,12 +720,13 @@ export class BrowserTitlebarPart extends Part implements ITitlebarPart { ); } - // --- Global Actions (after layout so e.g. notification bell appears to the right of layout controls) + // --- Global Actions (after layout so e.g. notification bell appears to the right of layout controls). + // Filter out the leading group up front so it isn't duplicated into the overflow `secondary` bucket. if (this.globalToolbarMenu) { + const trailingGroups = this.globalToolbarMenu.getActions().filter(([group]) => group !== TitleBarLeadingActionsGroup); fillInActionBarActions( - this.globalToolbarMenu.getActions(), - actions, - actionGroup => actionGroup !== TitleBarLeadingActionsGroup // already rendered before layout controls + trailingGroups, + actions ); } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index 8f207dac6fb81..d0bc340024683 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -2138,27 +2138,8 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const throttler = new Throttler(); reconnectDisposables.add(throttler); - // Wire up awaitConfirmation for tool calls that were already pending - // confirmation at snapshot time so the user can approve/deny them. - // Also start observing any subagent tools that were already running. const cts = new CancellationTokenSource(); reconnectDisposables.add(toDisposable(() => cts.dispose(true))); - for (const [toolCallId, invocation] of activeToolInvocations) { - if (!IChatToolInvocation.isComplete(invocation)) { - // Look up the tool call state to forward protocol options on reconnection - const tcState = currentState?.activeTurn?.responseParts.find( - rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === toolCallId - ); - const tcOptions = tcState?.kind === ResponsePartKind.ToolCall && tcState.toolCall.status === ToolCallStatus.PendingConfirmation - ? tcState.toolCall.options - : undefined; - this._awaitToolConfirmation(invocation, toolCallId, backendSession, turnId, cts.token, tcOptions); - } - if (invocation.toolSpecificData?.kind === 'subagent' && !observedSubagentToolIds.has(toolCallId)) { - observedSubagentToolIds.add(toolCallId); - this._observeSubagentSession(backendSession, toolCallId, (parts) => chatSession.appendProgress(parts), reconnectDisposables, observedSubagentToolIds); - } - } // Track live input request carousels for reconnection const activeInputRequests = new Map(); @@ -2177,6 +2158,38 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC progress: parts => chatSession.appendProgress(parts), cancellationToken: cts.token, }; + + // Wire up tool calls from the initial progress snapshot. + // Client-owned tool calls are re-created through the client tool + // path so _tryInvokeClientTool can execute them. Server tool calls + // get confirmation wiring and subagent observation as before. + for (const [toolCallId, invocation] of activeToolInvocations) { + const tcState = currentState?.activeTurn?.responseParts.find( + rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === toolCallId + ); + const tc = tcState?.kind === ResponsePartKind.ToolCall ? tcState.toolCall : undefined; + + if (tc && tc.toolClientId === this._config.connection.clientId && !IChatToolInvocation.isComplete(invocation)) { + // Complete the snapshot invocation from activeTurnToProgress + // so it does not remain orphaned in the UI — the replacement + // created by _beginClientToolInvocation takes over. + invocation.didExecuteTool(undefined); + this._beginClientToolInvocation(tc, ctx); + this._tryInvokeClientTool(tc, ctx); + continue; + } + + if (!IChatToolInvocation.isComplete(invocation)) { + const tcOptions = tc?.status === ToolCallStatus.PendingConfirmation + ? tc.options + : undefined; + this._awaitToolConfirmation(invocation, toolCallId, backendSession, turnId, cts.token, tcOptions); + } + if (invocation.toolSpecificData?.kind === 'subagent' && !observedSubagentToolIds.has(toolCallId)) { + observedSubagentToolIds.add(toolCallId); + this._observeSubagentSession(backendSession, toolCallId, (parts) => chatSession.appendProgress(parts), reconnectDisposables, observedSubagentToolIds); + } + } const processStateChange = (sessionState: SessionState) => { const isActive = this._processSessionState(sessionState, ctx); this._syncInputRequests(activeInputRequests, sessionState.inputRequests, backendSession, chatSession.sessionResource, cts.token, appendProgress); diff --git a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts index 0b32734757b42..56116f2db87fe 100644 --- a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts @@ -197,6 +197,14 @@ export interface IToolInvocation { selectedCustomButton?: string; /** Pre-tool-use hook result passed from the extension, if the hook was already executed externally. */ preToolUseResult?: IExternalPreToolUseHookResult; + /** + * Optional W3C trace context `traceparent` value identifying the parent distributed + * tracing span for this tool invocation. Forwarded to MCP tool implementations as + * `_meta.traceparent` (MCP SEP-414). + */ + traceparent?: string; + /** Optional W3C trace context `tracestate` value paired with {@link traceparent}. */ + tracestate?: string; } export interface IToolInvocationContext { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts index 6e3463a7e6409..6b0b5e5b60a10 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts @@ -4,7 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { timeout } from '../../../../../../base/common/async.js'; import { VSBuffer } from '../../../../../../base/common/buffer.js'; +import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { DisposableStore, IReference, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { URI } from '../../../../../../base/common/uri.js'; @@ -13,15 +15,17 @@ import { mock } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; import { IConfigurationChangeEvent, IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; -import { IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; +import { AgentSession, IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; import { isSessionAction, type ActionEnvelope, type IRootConfigChangedAction, type SessionAction, type TerminalAction, type INotification } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { SessionLifecycle, SessionStatus, createSessionState, StateComponents, type SessionState, type SessionSummary, type RootState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { sessionReducer } from '../../../../../../platform/agentHost/common/state/sessionReducers.js'; -import { ToolResultContentType } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { ActionType } from '../../../../../../platform/agentHost/common/state/protocol/actions.js'; +import { ToolCallConfirmationReason, ToolResultContentType } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { IChatAgentService } from '../../../common/participants/chatAgents.js'; -import { IChatService } from '../../../common/chatService/chatService.js'; +import { IChatProgress, IChatService, IChatToolInvocation, ToolConfirmKind } from '../../../common/chatService/chatService.js'; import { IChatEditingService } from '../../../common/editing/chatEditingService.js'; import { ILanguageModelsService } from '../../../common/languageModels.js'; +import { ChatToolInvocation } from '../../../common/model/chatProgressTypes/chatToolInvocation.js'; import { IProductService } from '../../../../../../platform/product/common/productService.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; @@ -36,7 +40,7 @@ import { IAgentSubscription } from '../../../../../../platform/agentHost/common/ import { ITerminalChatService } from '../../../../terminal/browser/terminal.js'; import { IAgentHostTerminalService } from '../../../../terminal/browser/agentHostTerminalService.js'; import { IAgentHostSessionWorkingDirectoryResolver } from '../../../browser/agentSessions/agentHost/agentHostSessionWorkingDirectoryResolver.js'; -import { ILanguageModelToolsService, IToolData, IToolResult, ToolDataSource } from '../../../common/tools/languageModelToolsService.js'; +import { ILanguageModelToolsService, IToolData, IToolInvocation, IToolResult, ToolDataSource } from '../../../common/tools/languageModelToolsService.js'; import { IChatSessionsService } from '../../../common/chatSessionsService.js'; import { ICustomizationHarnessService } from '../../../common/customizationHarnessService.js'; import { IAgentPluginService } from '../../../common/plugins/agentPluginService.js'; @@ -234,6 +238,9 @@ suite('AgentHostClientTools', () => { function createMockToolsService(disposables: DisposableStore, tools: IToolData[]) { const onDidChangeTools = disposables.add(new Emitter()); + const pendingToolCalls = new Map(); + const begunToolCalls: ChatToolInvocation[] = []; + const invokedToolCalls: IToolInvocation[] = []; return { onDidChangeTools: onDidChangeTools.event, getToolByName: (name: string) => tools.find(t => t.toolReferenceName === name), @@ -243,9 +250,30 @@ suite('AgentHostClientTools', () => { registerTool: () => toDisposable(() => { }), getTools: () => tools, getAllToolsIncludingDisabled: () => tools, - getTool: () => undefined, - invokeTool: async () => ({ content: [] }), - beginToolCall: () => undefined, + getTool: (id: string) => tools.find(t => t.id === id), + invokeTool: async (invocation: IToolInvocation) => { + invokedToolCalls.push(invocation); + const toolInvocation = pendingToolCalls.get(invocation.chatStreamToolCallId ?? invocation.callId); + pendingToolCalls.delete(invocation.chatStreamToolCallId ?? invocation.callId); + toolInvocation?.transitionFromStreaming(undefined, invocation.parameters, { type: ToolConfirmKind.ConfirmationNotNeeded }); + const result: IToolResult = { content: [{ kind: 'text', value: 'done' }] }; + await toolInvocation?.didExecuteTool(result); + return result; + }, + beginToolCall: options => { + const toolData = tools.find(t => t.id === options.toolId); + if (!toolData) { + return undefined; + } + const invocation = ChatToolInvocation.createStreaming({ + toolCallId: options.toolCallId, + toolId: options.toolId, + toolData, + }); + pendingToolCalls.set(options.toolCallId, invocation); + begunToolCalls.push(invocation); + return invocation; + }, updateToolStream: async () => { }, cancelToolCallsForRequest: () => { }, flushToolUpdates: () => { }, @@ -269,7 +297,9 @@ suite('AgentHostClientTools', () => { onDidInvokeTool: Event.None, _serviceBrand: undefined, fireOnDidChangeTools: () => onDidChangeTools.fire(), - } satisfies ILanguageModelToolsService & { fireOnDidChangeTools: () => void }; + begunToolCalls, + invokedToolCalls, + } satisfies ILanguageModelToolsService & { fireOnDidChangeTools: () => void; begunToolCalls: ChatToolInvocation[]; invokedToolCalls: IToolInvocation[] }; } class MockAgentHostConnection extends mock() { @@ -287,22 +317,17 @@ suite('AgentHostClientTools', () => { override dispatch(action: SessionAction | TerminalAction | IRootConfigChangedAction): void { this.dispatchedActions.push(action); - if (isSessionAction(action) && action.type === 'session/activeClientChanged') { - const entry = this._liveSubscriptions.get(action.session); - if (entry) { - entry.state = sessionReducer(entry.state, action as Parameters[1], () => { }); - entry.emitter.fire(entry.state); - } - } - if (isSessionAction(action) && action.type === 'session/activeClientToolsChanged') { - const entry = this._liveSubscriptions.get(action.session); - if (entry) { - entry.state = sessionReducer(entry.state, action as Parameters[1], () => { }); - entry.emitter.fire(entry.state); - } + if (isSessionAction(action)) { + this.applySessionAction(action); } } + applySessionAction(action: SessionAction): void { + const entry = this._ensureLiveSubscription(action.session); + entry.state = sessionReducer(entry.state, action as Parameters[1], () => { }); + entry.emitter.fire(entry.state); + } + override readonly rootState: IAgentSubscription = { value: undefined, verifiedValue: undefined, @@ -313,18 +338,9 @@ suite('AgentHostClientTools', () => { override getSubscription(_kind: StateComponents, resource: URI): IReference> { const resourceStr = resource.toString(); - const emitter = disposables.add(new Emitter()); - const summary: SessionSummary = { - resource: resourceStr, - provider: 'copilot', - title: 'Test', - status: SessionStatus.Idle, - createdAt: Date.now(), - modifiedAt: Date.now(), - }; - const initialState: SessionState = { ...createSessionState(summary), lifecycle: SessionLifecycle.Ready }; - const entry = { state: initialState, emitter: emitter as unknown as Emitter }; - this._liveSubscriptions.set(resourceStr, entry); + this._ensureLiveSubscription(resourceStr); + const entry = this._liveSubscriptions.get(resourceStr)!; + const emitter = entry.emitter as unknown as Emitter; const self = this; const sub: IAgentSubscription = { @@ -341,6 +357,26 @@ suite('AgentHostClientTools', () => { }, }; } + + private _ensureLiveSubscription(resourceStr: string): { state: SessionState; emitter: Emitter } { + let entry = this._liveSubscriptions.get(resourceStr); + if (entry) { + return entry; + } + const emitter = disposables.add(new Emitter()); + const summary: SessionSummary = { + resource: resourceStr, + provider: 'copilot', + title: 'Test', + status: SessionStatus.Idle, + createdAt: Date.now(), + modifiedAt: Date.now(), + }; + const initialState: SessionState = { ...createSessionState(summary), lifecycle: SessionLifecycle.Ready }; + entry = { state: initialState, emitter }; + this._liveSubscriptions.set(resourceStr, entry); + return entry; + } } function createHandlerWithMocks( @@ -515,5 +551,106 @@ suite('AgentHostClientTools', () => { const def = toolDataToDefinition(testRunTestsTool); assert.strictEqual(def.name, 'runTests'); }); + + test('invokes an owned client tool when reconnecting to an active turn', async () => { + const { handler, connection, toolsService } = createHandlerWithMocks(disposables, [testRunTaskTool]); + const sessionResource = URI.parse('agent-host-copilot:/session-1'); + const backendSession = AgentSession.uri('copilot', 'session-1').toString(); + + connection.applySessionAction({ + type: ActionType.SessionTurnStarted, + session: backendSession, + turnId: 'turn-1', + userMessage: { text: 'run the task' }, + } as SessionAction); + connection.applySessionAction({ + type: ActionType.SessionToolCallStart, + session: backendSession, + turnId: 'turn-1', + toolCallId: 'tool-call-1', + toolName: 'runTask', + displayName: 'Run Task', + toolClientId: connection.clientId, + } as SessionAction); + connection.applySessionAction({ + type: ActionType.SessionToolCallReady, + session: backendSession, + turnId: 'turn-1', + toolCallId: 'tool-call-1', + invocationMessage: 'Run Task', + toolInput: '{"task":"build"}', + confirmed: ToolCallConfirmationReason.NotNeeded, + } as SessionAction); + + await handler.provideChatSessionContent(sessionResource, CancellationToken.None); + await timeout(0); + await timeout(0); + + assert.deepStrictEqual(toolsService.invokedToolCalls.map(call => ({ + callId: call.callId, + toolId: call.toolId, + parameters: call.parameters, + chatStreamToolCallId: call.chatStreamToolCallId, + })), [{ + callId: 'tool-call-1', + toolId: 'vscode.runTask', + parameters: { task: 'build' }, + chatStreamToolCallId: 'tool-call-1', + }]); + assert.ok(connection.dispatchedActions.some(action => isSessionAction(action) + && action.type === ActionType.SessionToolCallComplete + && action.toolCallId === 'tool-call-1')); + }); + + test('reconnecting to an active turn with owned client tool completes the initial snapshot invocation', async () => { + const { handler, connection } = createHandlerWithMocks(disposables, [testRunTaskTool]); + const sessionResource = URI.parse('agent-host-copilot:/session-1'); + const backendSession = AgentSession.uri('copilot', 'session-1').toString(); + + connection.applySessionAction({ + type: ActionType.SessionTurnStarted, + session: backendSession, + turnId: 'turn-1', + userMessage: { text: 'run the task' }, + } as SessionAction); + connection.applySessionAction({ + type: ActionType.SessionToolCallStart, + session: backendSession, + turnId: 'turn-1', + toolCallId: 'tool-call-1', + toolName: 'runTask', + displayName: 'Run Task', + toolClientId: connection.clientId, + } as SessionAction); + connection.applySessionAction({ + type: ActionType.SessionToolCallReady, + session: backendSession, + turnId: 'turn-1', + toolCallId: 'tool-call-1', + invocationMessage: 'Run Task', + toolInput: '{"task":"build"}', + confirmed: ToolCallConfirmationReason.NotNeeded, + } as SessionAction); + + const session = await handler.provideChatSessionContent(sessionResource, CancellationToken.None); + + // activeTurnToProgress creates a generic ChatToolInvocation for + // the running client tool which appears in the session's progress + // observable. Grab it before _reconnectToActiveTurn replaces it. + const snapshotInvocation = (session as unknown as { progressObs: { get(): IChatProgress[] } }) + .progressObs.get() + .find((p): p is ChatToolInvocation => p instanceof ChatToolInvocation && p.toolCallId === 'tool-call-1'); + assert.ok(snapshotInvocation, 'activeTurnToProgress should have created a snapshot invocation'); + + await timeout(0); + await timeout(0); + + // The snapshot invocation from activeTurnToProgress should have + // been completed (via didExecuteTool) so it does not remain + // orphaned in the UI while the replacement from + // _beginClientToolInvocation takes over. + assert.ok(IChatToolInvocation.isComplete(snapshotInvocation), + 'the initial snapshot invocation should be completed, not orphaned'); + }); }); }); diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatZoneWidget.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatZoneWidget.ts index a0f9b20182a95..586fc01009c51 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatZoneWidget.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatZoneWidget.ts @@ -2,17 +2,17 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { addDisposableListener, Dimension, $ } from '../../../../base/browser/dom.js'; +import { addDisposableListener, Dimension, $, getWindow } from '../../../../base/browser/dom.js'; import * as aria from '../../../../base/browser/ui/aria/aria.js'; import { renderMarkdown, renderAsPlaintext } from '../../../../base/browser/markdownRenderer.js'; import { DomScrollableElement } from '../../../../base/browser/ui/scrollbar/scrollableElement.js'; import { ActionViewItem } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; import { ActionRunner, IAction } from '../../../../base/common/actions.js'; import { IMarkdownString, MarkdownString } from '../../../../base/common/htmlContent.js'; -import { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; +import { DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { autorun, observableValue } from '../../../../base/common/observable.js'; import { isEqual } from '../../../../base/common/resources.js'; -import { Event } from '../../../../base/common/event.js'; +import { Emitter } from '../../../../base/common/event.js'; import { ScrollbarVisibility } from '../../../../base/common/scrollable.js'; import { assertType } from '../../../../base/common/types.js'; import { ICodeEditor } from '../../../../editor/browser/editorBrowser.js'; @@ -79,6 +79,22 @@ export class InlineChatZoneWidget extends ZoneWidget { ordinal: 50000, }; + static readonly #instances = new Set(); + static readonly #statusDidChange = new Emitter(); + static #factoryRegistration: IDisposable | undefined; + + static #findByDom(element: HTMLElement): InlineChatZoneWidget | undefined { + const widgetDom = element.closest('.inline-chat-widget'); + if (widgetDom) { + for (const instance of InlineChatZoneWidget.#instances) { + if (instance.domNode === widgetDom) { + return instance; + } + } + } + return undefined; + } + readonly widget: EditorBasedInlineChatWidget; readonly status = observableValue(this, ''); @@ -146,21 +162,54 @@ export class InlineChatZoneWidget extends ZoneWidget { this.#ctxHasStatus.set(!!this.status.read(r)); })); - this._disposables.add(actionViewItemService.register(MenuId.ChatInput, StatusPlaceholder.Id, (action, options) => { - const that = this; - const item = new class extends ActionViewItem { - override render(container: HTMLElement): void { - super.render(container); - container.classList.add('status-placeholder'); - this._store.add(autorun(r => { - const value = that.status.read(r); - this.action.label = value ?? ''; - this.updateLabel(); - })); - } - }(undefined, action, { ...options, icon: false, label: true }); - return item; - }, Event.fromObservable(this.status, this._disposables))); + // Track this instance so the singleton factory can dispatch by DOM containment + InlineChatZoneWidget.#instances.add(this); + this._disposables.add(toDisposable(() => { + InlineChatZoneWidget.#instances.delete(this); + if (InlineChatZoneWidget.#instances.size === 0) { + InlineChatZoneWidget.#factoryRegistration?.dispose(); + InlineChatZoneWidget.#factoryRegistration = undefined; + } + })); + this._disposables.add(autorun(r => { + this.status.read(r); + InlineChatZoneWidget.#statusDidChange.fire(); + })); + + // Register a single factory for the status placeholder action. Multiple zone widget + // instances can coexist (one per editor) so the factory uses DOM containment to find + // the owning widget and observe its status. + if (!InlineChatZoneWidget.#factoryRegistration) { + InlineChatZoneWidget.#factoryRegistration = actionViewItemService.register(MenuId.ChatInput, StatusPlaceholder.Id, (action, options) => { + const item = new class extends ActionViewItem { + override render(container: HTMLElement): void { + super.render(container); + container.classList.add('status-placeholder'); + // Defer the DOM-based widget lookup to the next animation frame + // because actionbar calls render() before appending the element + // to the DOM, so closest() would fail during render(). + const targetWindow = getWindow(container); + let handle = targetWindow.requestAnimationFrame(() => { + handle = 0; + const widget = InlineChatZoneWidget.#findByDom(container); + if (widget) { + this._store.add(autorun(r => { + const value = widget.status.read(r) ?? ''; + this.action.label = value; + this.updateLabel(); + })); + } + }); + this._store.add(toDisposable(() => { + if (handle) { + targetWindow.cancelAnimationFrame(handle); + } + })); + } + }(undefined, action, { ...options, icon: false, label: true }); + return item; + }, InlineChatZoneWidget.#statusDidChange.event); + } this.widget = instaService.createInstance(EditorBasedInlineChatWidget, location, this.editor, { statusMenuId: { diff --git a/src/vs/workbench/contrib/mcp/common/mcpLanguageModelToolContribution.ts b/src/vs/workbench/contrib/mcp/common/mcpLanguageModelToolContribution.ts index 28400d12acfd6..4bb200467794a 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpLanguageModelToolContribution.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpLanguageModelToolContribution.ts @@ -266,7 +266,12 @@ class McpToolImplementation implements IToolImpl { content: [] }; - const callResult = await this._tool.callWithProgress(invocation.parameters as Record, progress, { chatRequestId: invocation.chatRequestId, chatSessionResource: invocation.context?.sessionResource }, token); + const callResult = await this._tool.callWithProgress(invocation.parameters as Record, progress, { + chatRequestId: invocation.chatRequestId, + chatSessionResource: invocation.context?.sessionResource, + traceparent: invocation.traceparent, + tracestate: invocation.tracestate, + }, token); const details: Mutable = { input: JSON.stringify(invocation.parameters, undefined, 2), output: [], diff --git a/src/vs/workbench/contrib/mcp/common/mcpServer.ts b/src/vs/workbench/contrib/mcp/common/mcpServer.ts index 9f93c186e584e..93edbedde183c 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpServer.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpServer.ts @@ -1199,6 +1199,14 @@ export class McpTool implements IMcpTool { if (context?.chatRequestId) { meta['vscode.requestId'] = context.chatRequestId; } + // Propagate W3C trace context to the MCP server (MCP SEP-414) so server-side + // spans can be correlated with the client trace. + if (context?.traceparent) { + meta['traceparent'] = context.traceparent; + if (context.tracestate) { + meta['tracestate'] = context.tracestate; + } + } const taskHint = this._definition.execution?.taskSupport; const serverSupportsTasksForTools = h.capabilities.tasks?.requests?.tools?.call !== undefined; diff --git a/src/vs/workbench/contrib/mcp/common/mcpTypes.ts b/src/vs/workbench/contrib/mcp/common/mcpTypes.ts index f4fa7a32d9673..bd491bb6647e1 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpTypes.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpTypes.ts @@ -453,6 +453,13 @@ export interface IMcpPromptMessage extends MCP.PromptMessage { } export interface IMcpToolCallContext { chatSessionResource: URI | undefined; chatRequestId?: string; + /** + * Optional W3C trace context `traceparent` value to forward to the MCP server + * via `_meta.traceparent` on the JSON-RPC `tools/call` request (MCP SEP-414). + */ + traceparent?: string; + /** Optional W3C trace context `tracestate` value paired with {@link traceparent}. */ + tracestate?: string; } /** diff --git a/src/vs/workbench/contrib/mcp/test/common/mcpServerRequestHandler.test.ts b/src/vs/workbench/contrib/mcp/test/common/mcpServerRequestHandler.test.ts index c74df032d4164..97852b9e0b22e 100644 --- a/src/vs/workbench/contrib/mcp/test/common/mcpServerRequestHandler.test.ts +++ b/src/vs/workbench/contrib/mcp/test/common/mcpServerRequestHandler.test.ts @@ -381,6 +381,34 @@ suite('Workbench - MCP - ServerRequestHandler', () => { assert.strictEqual(e.name, 'Canceled'); } }); + + test('callTool forwards _meta.traceparent to the JSON-RPC payload (MCP SEP-414)', async () => { + const traceparent = '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01'; + const tracestate = 'rojo=00f067aa0ba902b7'; + + const callPromise = handler.callTool({ + name: 'echo', + arguments: { hello: 'world' }, + _meta: { traceparent, tracestate, progressToken: 'tok-1' }, + }); + + const sentMessages = transport.getSentMessages(); + const callRequest = sentMessages[2] as MCP.JSONRPCRequest & MCP.CallToolRequest; + assert.strictEqual(callRequest.method, 'tools/call'); + assert.deepStrictEqual(callRequest.params._meta, { + traceparent, + tracestate, + progressToken: 'tok-1', + }); + + transport.simulateReceiveMessage({ + jsonrpc: MCP.JSONRPC_VERSION, + id: callRequest.id, + result: { content: [] }, + }); + + await callPromise; + }); }); suite.skip('Workbench - MCP - McpTask', () => { // TODO@connor4312 https://github.com/microsoft/vscode/issues/280126 diff --git a/src/vs/workbench/contrib/multiDiffEditor/browser/multiDiffEditorInput.ts b/src/vs/workbench/contrib/multiDiffEditor/browser/multiDiffEditorInput.ts index 9c3b0f6083cc8..7cb8aff0c8419 100644 --- a/src/vs/workbench/contrib/multiDiffEditor/browser/multiDiffEditorInput.ts +++ b/src/vs/workbench/contrib/multiDiffEditor/browser/multiDiffEditorInput.ts @@ -98,7 +98,7 @@ export class MultiDiffEditorInput extends EditorInput implements ILanguageSuppor this._register(model); const vm = new MultiDiffEditorViewModel(model, this._instantiationService); this._register(vm); - await raceTimeout(vm.waitForDiffs(), 1000); + await raceTimeout(vm.waitForDiffOr1s(), 1000); return vm; }); this._resolvedSource = new ObservableLazyPromise(async () => { @@ -276,7 +276,7 @@ export class MultiDiffEditorInput extends EditorInput implements ILanguageSuppor return this; } - override revert(group: GroupIdentifier, options?: IRevertOptions): Promise { + override revert(group: GroupIdentifier, options?: IRevertOptions): Promise { return this.doSaveOrRevert('revert', group, options); } diff --git a/src/vs/workbench/electron-browser/actions/media/openInAgents.css b/src/vs/workbench/electron-browser/actions/media/openInAgents.css index 6a8fb53f187e3..332f60b418987 100644 --- a/src/vs/workbench/electron-browser/actions/media/openInAgents.css +++ b/src/vs/workbench/electron-browser/actions/media/openInAgents.css @@ -38,8 +38,8 @@ background-repeat: no-repeat; background-position: center center; background-size: contain; - /* Desaturated at rest; full color on hover/focus. */ - filter: grayscale(1) opacity(0.75); + /* Keep desaturated for legibility against light/dark titlebar backgrounds; brighten on hover/focus. */ + filter: grayscale(1); transition: filter 150ms ease; } diff --git a/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts b/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts index 3579abfa98190..644f0b2095742 100644 --- a/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts +++ b/src/vs/workbench/electron-browser/actions/openInAgentsAction.ts @@ -9,6 +9,7 @@ import { getDefaultHoverDelegate } from '../../../base/browser/ui/hover/hoverDel import { BaseActionViewItem, IBaseActionViewItemOptions } from '../../../base/browser/ui/actionbar/actionViewItems.js'; import { IAction } from '../../../base/common/actions.js'; import { Disposable } from '../../../base/common/lifecycle.js'; +import { isMacintosh, isWindows } from '../../../base/common/platform.js'; import { localize, localize2 } from '../../../nls.js'; import { Action2, MenuId, registerAction2 } from '../../../platform/actions/common/actions.js'; import { IActionViewItemService } from '../../../platform/actions/browser/actionViewItemService.js'; @@ -82,13 +83,6 @@ class OpenInAgentsAction extends Action2 { group: TitleBarLeadingActionsGroup, order: -1000, when: OpenInAgentsVisibility, - }, { - // Also surface inside the "Customize Layout..." submenu so users - // can toggle the entry on/off from the layout customization UI. - id: MenuId.LayoutControlMenuSubmenu, - group: '0_workbench_layout', - order: -1000, - when: OpenInAgentsVisibility, }] }); } @@ -122,9 +116,11 @@ class OpenInAgentsAction extends Action2 { ); // In built builds with a sibling Agents app available, launch it. - // Otherwise (dev / OSS / no sibling), open a new agents window of - // the current Electron app. - const mode: OpenInAgentsMode = environmentService.isBuilt && hasSibling ? 'siblingApp' : 'newWindow'; + // Otherwise (dev / OSS / unsupported platform / no sibling), open a new agents window of + // the current Electron app. `launchSiblingApp` is only implemented for macOS/Windows + // (see `src/vs/platform/native/node/siblingApp.ts`), so gate on actual platform support. + const canLaunchSiblingApp = isMacintosh || isWindows; + const mode: OpenInAgentsMode = environmentService.isBuilt && hasSibling && canLaunchSiblingApp ? 'siblingApp' : 'newWindow'; telemetryService.publicLog2('vscode.openInAgents', { mode }); if (mode === 'siblingApp') { diff --git a/src/vs/workbench/test/browser/componentFixtures/editor/inlineChatZoneWidget.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/editor/inlineChatZoneWidget.fixture.ts index b22dea113a28d..a07d465f6843a 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/inlineChatZoneWidget.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/editor/inlineChatZoneWidget.fixture.ts @@ -25,6 +25,7 @@ import { IDecorationsService } from '../../../../services/decorations/common/dec import { ITextFileService } from '../../../../services/textfile/common/textfiles.js'; import { IWorkbenchAssignmentService } from '../../../../services/assignment/common/assignmentService.js'; import { IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; +import { IChatInputNotificationService } from '../../../../contrib/chat/browser/widget/input/chatInputNotificationService.js'; import { IPathService } from '../../../../services/path/common/pathService.js'; import { IChatWidgetService, IChatAccessibilityService } from '../../../../contrib/chat/browser/chat.js'; import { IChatContextPickService } from '../../../../contrib/chat/browser/attachments/chatContextPickService.js'; @@ -63,6 +64,14 @@ import { IMarkdownRendererService, MarkdownRendererService } from '../../../../. import { observableValue } from '../../../../../base/common/observable.js'; import { ComponentFixtureContext, createEditorServices, createTextModel, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../fixtureUtils.js'; import { InlineChatZoneWidget } from '../../../../contrib/inlineChat/browser/inlineChatZoneWidget.js'; +import { ChatModel } from '../../../../contrib/chat/common/model/chatModel.js'; +import { IChatEditingService } from '../../../../contrib/chat/common/editing/chatEditingService.js'; +import { Target } from '../../../../contrib/chat/common/promptSyntax/promptTypes.js'; +import { ICustomizationHarnessService } from '../../../../contrib/chat/common/customizationHarnessService.js'; + +// Side-effect import: registers InputEditorDecorations into ChatWidget.CONTRIBS +// so the placeholder decoration is rendered. +import '../../../../contrib/chat/browser/widget/input/editor/chatInputEditorContrib.js'; // CSS imports import '../../../../contrib/inlineChat/browser/media/inlineChat.css'; @@ -203,9 +212,11 @@ function renderInlineChatZoneWidget({ container, disposableStore, theme }: Compo }()); reg.defineInstance(IChatTipService, new class extends mock() { readonly onDidReceiveTip = Event.None; + override resetSession() { } }()); reg.defineInstance(IChatDebugService, new class extends mock() { override readonly onDidAddEvent = Event.None; + override getEvents() { return []; } }()); reg.defineInstance(IChatEntitlementService, new class extends mock() { override readonly sentimentObs = observableValue('sentiment', { completed: true }); @@ -222,10 +233,26 @@ function renderInlineChatZoneWidget({ container, disposableStore, theme }: Compo override readonly onDidChangeSessionOptions = Event.None; override readonly onDidChangeOptionGroups = Event.None; override readonly onDidChangeAvailability = Event.None; + override readonly onDidChangeCustomizations = Event.None; + override readonly onDidChangeContentProviderSchemes = Event.None; + override readonly onDidChangeItemsProviders = Event.None; + override readonly onDidChangeSessionItems = Event.None; + override readonly onDidCommitSession = Event.None; + override readonly onDidChangeInProgress = Event.None; + override sessionSupportsFork() { return false; } + override supportsDelegationForSessionType() { return false; } + override getOptionGroupsForSessionType() { return undefined; } + override getCustomAgentTargetForSessionType() { return Target.Undefined; } + override requiresCustomModelsForSessionType() { return false; } + override getChatSessionContribution() { return undefined; } + override getCapabilitiesForSessionType() { return undefined; } + override getSessionOptions() { return undefined; } + override hasCustomizationsProvider() { return false; } }()); reg.defineInstance(ILanguageModelsService, new class extends mock() { override readonly onDidChangeLanguageModels = Event.None; override getLanguageModelIds() { return []; } + override getVendors() { return []; } }()); reg.defineInstance(ILanguageModelToolsService, new class extends mock() { override readonly onDidChangeTools = Event.None; @@ -263,6 +290,17 @@ function renderInlineChatZoneWidget({ container, disposableStore, theme }: Compo override getHistory() { return []; } override readonly onDidChangeHistory = Event.None; }()); + reg.defineInstance(IChatEditingService, new class extends mock() { + override editingSessionsObs = observableValue('editingSessionsObs', []); + }()); + reg.defineInstance(IChatInputNotificationService, new class extends mock() { + override readonly onDidChange = Event.None; + override getActiveNotification() { return undefined; } + }()); + reg.defineInstance(ICustomizationHarnessService, new class extends mock() { + override readonly onDidChangeSlashCommands = Event.None; + override readonly onDidChangeCustomAgents = Event.None; + }()); reg.defineInstance(IChatContextPickService, new class extends mock() { }()); reg.defineInstance(IDecorationsService, new class extends mock() { override readonly onDidChangeDecorations = Event.None; }()); reg.defineInstance(ITextFileService, new class extends mock() { override readonly untitled = new class extends mock() { override readonly onDidChangeLabel = Event.None; }(); }()); @@ -362,6 +400,10 @@ function renderInlineChatZoneWidget({ container, disposableStore, theme }: Compo zoneWidget.show(new Position(10, 1)); + const dummyModel = instantiationService.createInstance(ChatModel, undefined, { initialLocation: ChatAgentLocation.EditorInline, canUseTools: false }); + zoneWidget.widget.chatWidget.setModel(dummyModel); + zoneWidget.widget.chatWidget.setInputPlaceholder('Ask Copilot...'); + // Force a relayout after the initial show so that the chat widget's // contentHeight (which includes the toolbar row rendered below the input) // is fully measured and the zone widget adjusts its height accordingly. diff --git a/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts b/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts index 2276e22cba05c..51bdf465a0da6 100644 --- a/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts +++ b/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts @@ -303,6 +303,17 @@ declare module 'vscode' { * Unique ID for the subagent invocation, used to group tool calls from the same subagent run together. */ subAgentInvocationId?: string; + /** + * W3C trace context `traceparent` header value identifying the active distributed + * tracing span. When provided to a tool implementation backed by an MCP server, this + * value is forwarded as `_meta.traceparent` on the JSON-RPC `tools/call` request so + * downstream servers can correlate their spans (MCP SEP-414). + */ + traceparent?: string; + /** + * Optional W3C trace context `tracestate` header value paired with `traceparent`. + */ + tracestate?: string; /** * Pre-tool-use hook result, if the hook was already executed by the caller. * When provided, the tools service will skip executing its own preToolUse hook diff --git a/test/componentFixtures/blocks-ci-screenshots.md b/test/componentFixtures/blocks-ci-screenshots.md index 7da2bff277f15..22d7ab9005134 100644 --- a/test/componentFixtures/blocks-ci-screenshots.md +++ b/test/componentFixtures/blocks-ci-screenshots.md @@ -7,10 +7,10 @@ ![screenshot](https://hediet-screenshots.azurewebsites.net/images/42624fbba5e0db7f32c224b5eb9c5dd3b08245697ae2e7d2a88be0d7c287129b) #### editor/inlineChatZoneWidget/InlineChatZoneWidget/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/7d1a6d2346754115e77fc2b0b09a0e6fb6fd9fe22acbff6354813eefb8b45fc2) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/041fd8cf01bf03e44367c80186743d6b4eae3aa7b38a6a1551e8a0168cb8f8f7) #### editor/inlineChatZoneWidget/InlineChatZoneWidget/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/11dbc075c584b7dde0f08314e98db121e420529ced6249effb941cfe2ae3164b) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/64135435974ab9866b41b95e4fd1a7b2fb87b5b4c551617c361ca0d36eb75569) #### editor/inlineChatZoneWidget/InlineChatZoneWidgetTerminated/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/2fbc12507b59ff950d9612d2df92e6b39d8bf0bf500478e42eca2ead4d1ae206)