Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -239,15 +239,19 @@ export function resolveCopilotConfigSlashCommandOnSend(command: string, rest: st
}
const trimmedRest = rest.trim();
const namedOptions = descriptor.options.filter(o => o.arg !== undefined);
const baseOption = descriptor.options.find(o => o.arg === undefined);
if (namedOptions.length > 0 && trimmedRest.length > 0) {
const match = /^(\S+)(?:\s+([\s\S]*))?$/.exec(trimmedRest);
const firstToken = match?.[1]?.toLowerCase();
const matched = namedOptions.find(o => o.arg?.toLowerCase() === firstToken);
if (matched) {
return { applyConfig: matched.config, strippedPrompt: (match?.[2] ?? '').trim() };
}
if (!baseOption) {
return undefined;
}
}
// Fall back to the bare command form (the base/prompt option or the sole option).
const baseOption = descriptor.options.find(o => o.arg === undefined) ?? descriptor.options[0];
return { applyConfig: baseOption.config, strippedPrompt: trimmedRest };
const fallback = baseOption ?? descriptor.options[0];
return { applyConfig: fallback.config, strippedPrompt: trimmedRest };
}
12 changes: 12 additions & 0 deletions src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,37 +41,46 @@ export type IAgentHostUserMessageSentClassification = {
};

export type AgentHostTurnResult = 'success' | 'error' | 'cancelled';
type AgentHostModelSelectionKind = 'default' | 'auto' | 'explicit';

export interface IAgentHostTurnCompletedEvent {
provider: string;
agentSessionId: string;
turnId: string;
timeToFirstProgress: number | undefined;
totalTime: number;
result: AgentHostTurnResult;
model: string | undefined;
modelSelectionKind: AgentHostModelSelectionKind;
permissionLevel: string | undefined;
errorType: string | undefined;
}

export type IAgentHostTurnCompletedClassification = {
provider: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The provider handling the agent host session.' };
agentSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The agent host session identifier.' };
turnId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The identifier of the turn within the agent host session.' };
timeToFirstProgress: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Time in milliseconds from turn start to the first visible progress (text delta, response part, tool call start, or reasoning).' };
totalTime: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Total time in milliseconds from turn start to turn completion.' };
result: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the turn completed successfully, with an error, or was cancelled.' };
model: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The model identifier selected for the session at turn start (e.g. gemini-3.5-flash).' };
modelSelectionKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the client used the provider default, Auto, or an explicit model.' };
permissionLevel: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The tool auto-approval level configured for the session at turn start (e.g. default, autoApprove, autopilot).' };
errorType: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The structured agent host or provider error type when the turn fails.' };
owner: 'roblourens';
comment: 'Tracks agent host turn performance including time to first visible progress and total turn duration.';
};

export interface IAgentHostTurnCompletedReport {
provider: string;
session: string;
turnId: string;
timeToFirstProgress: number | undefined;
totalTime: number;
result: AgentHostTurnResult;
model: string | undefined;
permissionLevel: string | undefined;
errorType: string | undefined;
}

export interface IAgentHostToolInvokedReport {
Expand Down Expand Up @@ -429,11 +438,14 @@ export class AgentHostTelemetryReporter {
this._telemetryService.publicLog2<IAgentHostTurnCompletedEvent, IAgentHostTurnCompletedClassification>('agentHost.turnCompleted', {
provider: report.provider,
agentSessionId: AgentSession.id(session),
turnId: report.turnId,
timeToFirstProgress: report.timeToFirstProgress,
totalTime: report.totalTime,
result: report.result,
model: report.model,
modelSelectionKind: report.model === undefined ? 'default' : report.model === 'auto' ? 'auto' : 'explicit',
permissionLevel: report.permissionLevel,
errorType: report.errorType,
});
}

Expand Down
30 changes: 19 additions & 11 deletions src/vs/platform/agentHost/node/agentHostTerminalManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -603,9 +603,24 @@ export class AgentHostTerminalManager extends Disposable implements IAgentHostTe
? tracker.parser.parseSegments(rawData)
: (rawData.length > 0 ? [{ kind: 'data', data: rawData }] : []);

let cleanedForClient = '';
// Preserve OSC 633 stream order when emitting AHP actions: command data must remain between
// TerminalCommandExecuted and TerminalCommandFinished, matching the AHP contract and xterm.
let pendingClientData = '';
const flushClientData = (): void => {
if (pendingClientData.length === 0) {
return;
}
managed.onDataEmitter.fire(pendingClientData);
this._stateManager.dispatchServerAction(managed.uri, {
type: ActionType.TerminalData,
data: pendingClientData,
});
pendingClientData = '';
};

for (const segment of segments) {
if (segment.kind === 'event') {
flushClientData();
this._handleOsc633Event(managed, tracker!, segment.event);
continue;
}
Expand All @@ -616,21 +631,14 @@ export class AgentHostTerminalManager extends Disposable implements IAgentHostTe
const cleanedData = removeServerHandledTerminalQueries(segment.data, managed.terminalQueryFilterState);
if (cleanedData.length > 0) {
this._appendToContent(managed, cleanedData);
cleanedForClient += cleanedData;
pendingClientData += cleanedData;
}
}

flushClientData();

// Trim content if too large
this._trimContent(managed);

// Fire data event and dispatch to protocol (cleaned, without OSC 633)
if (cleanedForClient.length > 0) {
managed.onDataEmitter.fire(cleanedForClient);
this._stateManager.dispatchServerAction(managed.uri, {
type: ActionType.TerminalData,
data: cleanedForClient,
});
}
}

/** Handle a parsed OSC 633 event by dispatching the appropriate protocol actions. */
Expand Down
4 changes: 3 additions & 1 deletion src/vs/platform/agentHost/node/agentHostTurnTracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export class AgentHostTurnTracker {
}
}

turnCompleted(session: string, turnId: string, result: AgentHostTurnResult): void {
turnCompleted(session: string, turnId: string, result: AgentHostTurnResult, errorType?: string): void {
const key = this._key(session, turnId);
const timing = this._turnTimings.get(key);
if (!timing) {
Expand All @@ -62,11 +62,13 @@ export class AgentHostTurnTracker {
this._reporter.turnCompleted({
provider: timing.provider,
session: timing.session,
turnId,
timeToFirstProgress: timing.firstProgressMs,
totalTime: timing.stopWatch.elapsed(),
result,
model: timing.model,
permissionLevel: timing.permissionLevel,
errorType,
});
}

Expand Down
16 changes: 9 additions & 7 deletions src/vs/platform/agentHost/node/agentSideEffects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -728,7 +728,7 @@ export class AgentSideEffects extends Disposable {
}

if (action.type === ActionType.ChatError) {
this._turnTracker.turnCompleted(sessionKey, turnId, 'error');
this._turnTracker.turnCompleted(sessionKey, turnId, 'error', action.error.errorType);
this._toolCallTracker.clearSession(sessionKey);
this._markSessionUnread(sessionUri);
}
Expand Down Expand Up @@ -1524,16 +1524,17 @@ export class AgentSideEffects extends Disposable {
const sessionStatus = this._stateManager.getSessionSummary(options.sessionChannel)?.status ?? 0;
const sessionArchived = (sessionStatus & SessionStatus.IsArchived) === SessionStatus.IsArchived;
if (isChatReadOnly(chatState?.interactivity, sessionArchived)) {
const error = sessionArchived
? { errorType: 'archived', message: 'This session is archived and read-only. Restore the session to continue the conversation.' }
: { errorType: 'readOnly', message: 'This chat is read-only.' };
this._logService.warn(`[AgentSideEffects] Rejecting turn on read-only chat=${chat} (archived=${sessionArchived}), turnId=${turnId}`);
this._stateManager.dispatchServerAction(turnChannel, {
type: ActionType.ChatError,
turnId,
duration: this._turnDuration(turnStopWatch),
error: sessionArchived
? { errorType: 'archived', message: 'This session is archived and read-only. Restore the session to continue the conversation.' }
: { errorType: 'readOnly', message: 'This chat is read-only.' },
error,
});
this._turnTracker.turnCompleted(turnChannel, turnId, 'error');
this._turnTracker.turnCompleted(turnChannel, turnId, 'error', error.errorType);
this._toolCallTracker.clearSession(turnChannel);
return;
}
Expand Down Expand Up @@ -1562,14 +1563,15 @@ export class AgentSideEffects extends Disposable {
await agent.chats.sendMessage(chatUri, message.text, resolvedWorkingDirectory, message.attachments, turnId, senderClientId);
} catch (err) {
const errCode = (err as { code?: number })?.code;
const error = buildSendFailedError(err);
this._logService.error(`[AgentSideEffects] sendMessage failed for session=${turnChannel}: code=${errCode}, message=${err instanceof Error ? err.message : String(err)}, type=${err?.constructor?.name}`, err);
this._stateManager.dispatchServerAction(turnChannel, {
type: ActionType.ChatError,
turnId,
duration: this._turnDuration(turnStopWatch),
error: buildSendFailedError(err),
error,
});
this._turnTracker.turnCompleted(turnChannel, turnId, 'error');
this._turnTracker.turnCompleted(turnChannel, turnId, 'error', error.errorType);
this._toolCallTracker.clearSession(turnChannel);
this._failSessionCreationIfStillCreating(sessionChannel, err);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,8 @@ export interface ICopilotRuntimeSlashCommandQueryOptions {
*
* The returned items carry a {@link MessageAttachmentKind.Simple}
* attachment, which the workbench bridge maps into command/skill completion
* attachments. Command dispatch happens text-side in
* `CopilotAgentSession.send` via {@link parseLeadingSlashCommand}, so the
* feature works whether the user picks the item or types it manually.
* attachments. Runtime command dispatch is text-side in `CopilotAgentSession.send`;
* client-side config commands also share the same leading slash parser.
*/
export class CopilotSlashCommandCompletionProvider implements IAgentHostCompletionItemProvider {
readonly kinds: ReadonlySet<CompletionItemKind> = new Set([CompletionItemKind.UserMessage]);
Expand Down
33 changes: 33 additions & 0 deletions src/vs/platform/agentHost/test/common/agentSubscription.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,39 @@ suite('TerminalStateSubscription', () => {
]);
});

test('data between command executed and finished is attributed to the command', () => {
const sub = disposables.add(new TerminalStateSubscription(terminalUri, 'c1', noop));
sub.handleSnapshot(makeTerminalState(), 0);

// The server dispatches data in stream order relative to command
// events, so a command's output arrives between the executed and
// finished actions and must land in the command part, not in a
// trailing unclassified part.
sub.receiveEnvelope(makeEnvelope(
{ type: ActionType.TerminalCommandExecuted, commandId: 'cmd-1', commandLine: 'echo hi', timestamp: 1000 },
1,
));
sub.receiveEnvelope(makeEnvelope(
{ type: ActionType.TerminalData, data: 'hi\r\n' },
2,
));
sub.receiveEnvelope(makeEnvelope(
{ type: ActionType.TerminalCommandFinished, commandId: 'cmd-1', exitCode: 0, durationMs: 5 },
3,
));

assert.deepStrictEqual((sub.value as TerminalState).content, [{
type: 'command',
commandId: 'cmd-1',
commandLine: 'echo hi',
output: 'hi\r\n',
timestamp: 1000,
isComplete: true,
exitCode: 0,
durationMs: 5,
}]);
});

test('ignores terminal actions for other URIs', () => {
const sub = disposables.add(new TerminalStateSubscription(terminalUri, 'c1', noop));
sub.handleSnapshot(makeTerminalState(), 0);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ suite('copilotConfigSlashCommands', () => {
assert.deepStrictEqual(resolveCopilotConfigSlashCommandOnSend('autopilot', 'do the thing'), { applyConfig: { mode: 'autopilot' }, strippedPrompt: 'do the thing' });
// `plan` has no sub-args, so trailing text is forwarded as the prompt.
assert.deepStrictEqual(resolveCopilotConfigSlashCommandOnSend('plan', 'the feature'), { applyConfig: { mode: 'plan' }, strippedPrompt: 'the feature' });
assert.strictEqual(resolveCopilotConfigSlashCommandOnSend('yolo', 'onxxxcva'), undefined);
assert.strictEqual(resolveCopilotConfigSlashCommandOnSend('allow-all', 'offxxxcva'), undefined);
assert.strictEqual(resolveCopilotConfigSlashCommandOnSend('notACommand', 'x'), undefined);
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,22 +58,39 @@ class TestTerminalDataHandler {
handlePtyData(rawData: string): string {
let cleanedForClient = '';

// Process cleaned-data and events in stream order so that output which
// arrives before a CommandFinished marker is appended to the command
// before the finished event snapshots it — see _handlePtyData.
// Data is dispatched in stream order relative to command events: flush
// pending data before handling each event so subscribers observe
// CommandExecuted -> data -> CommandFinished exactly like the raw
// stream — see _handlePtyData.
let pendingClientData = '';
const flushClientData = (): void => {
if (pendingClientData.length === 0) {
return;
}
this.dispatched.push({
type: ActionType.TerminalData,
data: pendingClientData,
});
cleanedForClient += pendingClientData;
pendingClientData = '';
};

for (const segment of this.tracker.parser.parseSegments(rawData)) {
if (segment.kind === 'event') {
flushClientData();
this._handleOsc633Event(segment.event);
continue;
}

const cleanedData = removeServerHandledTerminalQueries(segment.data, this._terminalQueryFilterState);
if (cleanedData.length > 0) {
this._appendToContent(cleanedData);
cleanedForClient += cleanedData;
pendingClientData += cleanedData;
}
}

flushClientData();

return cleanedForClient;
}

Expand Down Expand Up @@ -512,7 +529,11 @@ suite('AgentHostTerminalManager – command detection integration', () => {

assert.strictEqual(cleaned, 'beforemidafter');
assert.deepStrictEqual(handler.content, [{ type: 'unclassified', value: 'beforemidafter' }]);
assert.strictEqual(handler.dispatched[0].type, ActionType.TerminalCommandDetectionAvailable);
assert.deepStrictEqual(handler.dispatched, [
{ type: ActionType.TerminalData, data: 'before' },
{ type: ActionType.TerminalCommandDetectionAvailable },
{ type: ActionType.TerminalData, data: 'midafter' },
]);
});

test('TerminalCommandDetectionAvailable is dispatched on first OSC 633', () => {
Expand Down Expand Up @@ -687,7 +708,9 @@ suite('AgentHostTerminalManager – command detection integration', () => {
assert.deepStrictEqual(handler.content, [
{ type: 'unclassified', value: data },
]);
assert.deepStrictEqual(handler.dispatched, []);
assert.deepStrictEqual(handler.dispatched, [
{ type: ActionType.TerminalData, data },
]);
});

test('CommandFinished without active command is ignored', () => {
Expand Down Expand Up @@ -748,8 +771,26 @@ suite('AgentHostTerminalManager – command detection integration', () => {
output: event.output,
})));

// Clients rebuild per-command output from the action stream, so the
// data must also be DISPATCHED between the executed and finished
// actions, not after the whole chunk.
const dispatched: { type: string; data?: string }[] = [];
disposables.add(stateManager.onDidEmitEnvelope(envelope => {
const action = envelope.action;
if (action.type === ActionType.TerminalCommandExecuted || action.type === ActionType.TerminalCommandFinished) {
dispatched.push({ type: action.type });
} else if (action.type === ActionType.TerminalData) {
dispatched.push({ type: action.type, data: action.data });
}
}));

pty.fireData(`${osc633('C')}hi\r\n${osc633('D;0')}`);

assert.deepStrictEqual(completions, [{ exitCode: 0, output: 'hi\r\n' }]);
assert.deepStrictEqual(dispatched, [
{ type: ActionType.TerminalCommandExecuted },
{ type: ActionType.TerminalData, data: 'hi\r\n' },
{ type: ActionType.TerminalCommandFinished },
]);
});
});
Loading
Loading