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
2 changes: 2 additions & 0 deletions src/vs/platform/agentHost/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,8 @@ Customization discovery (agents, skills, instructions and workspace MCP configur

Steering received during turn preparation or configuration refresh is buffered for the addressed chat. A dispatched turn confirmed running by root SDK events accepts and flushes steering immediately, even if its admission RPC has not returned; successful admission also flushes it. Preparation or send failure retains buffered steering for a successful retry, rather than dispatching against an unready runtime. Pending updates replace or remove buffered steering; abort clears it and emits `steering_consumed` to clear the pending UI. The live SDK-session wrapper takes ownership synchronously before MCP reconciliation, so abort and disposal can clear steering throughout delivery. Chat disposal and host shutdown discard buffered delivery, and pending-message updates are ignored once host shutdown starts.

AHP stores steering and queued messages together in `queuedMessages`, distinguished by `PendingMessage.kind`. Steering entries use `IAgent.sendSteeringMessage` individually, bypassing Copilot's replaceable legacy-slot buffer. The host retries deferred handoff on provider progress and correlates consumption by message ID. Cancellation clears every pending steering entry, including prompts handed off but not yet consumed by the SDK, without draining queued follow-ups.

`CopilotSessionLauncher` sets `mcpOAuthTokenStorage: 'in-memory'` for created, resumed, and ephemeral SDK sessions. VS Code owns durable MCP credentials through `onMcpAuthRequest`; the runtime must not consult its persistent MCP OAuth keychain store.

`CopilotSessionLauncher` opts created, resumed, and ephemeral chat sessions into `interactiveUser: true`, including resume-to-create fallbacks. Supporting runtimes use this declaration for user-message sentiment, task-completion criteria, and task analytics; automated messages remain excluded by the runtime. Hidden customization-policy sessions do not opt in. This is runtime-owned GitHub analytics, not Agent Host's OTel export pipeline, and requires an SDK/runtime version that supports the option.
Expand Down
7 changes: 5 additions & 2 deletions src/vs/platform/agentHost/common/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1696,8 +1696,11 @@ export interface IAgent {
/** Re-attach an exact chat from opaque provider data without inferring its role. */
materializeChat(chat: URI, context: URI | IAgentChatContext, providerData: string | undefined): Promise<IAgentCreateChatResult | void>;

/** Optional steering hook for providers that can accept messages during an active turn. */
setPendingMessages?(chat: URI, steeringMessage: PendingMessage | undefined, queuedMessages: readonly PendingMessage[], steeringSender?: IAgentPendingMessageSender): void;
/** Optional steering hook. Return false to defer handoff until the provider's turn is ready. */
setPendingMessages?(chat: URI, steeringMessage: PendingMessage | undefined, queuedMessages: readonly PendingMessage[], steeringSender?: IAgentPendingMessageSender): boolean | void;

/** Hands off one independent submission without replacing the legacy slot; false defers until ready. */
sendSteeringMessage?(chat: URI, message: PendingMessage, sender?: IAgentPendingMessageSender): boolean | void;

/** Optional history mutation for providers with a native truncation operation. */
truncateChat?(chat: URI, turnId: string | undefined, context?: URI | IAgentChatContext): Promise<void>;
Expand Down
14 changes: 11 additions & 3 deletions src/vs/platform/agentHost/common/agentSessionMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,28 @@ import { isEqual } from '../../../base/common/resources.js';
import { hasKey } from '../../../base/common/types.js';
import { URI } from '../../../base/common/uri.js';
import { readAgentMessageDelegationMeta, type IAgentMessageSessionDelegationMeta } from './meta/agentMessageDelegationMeta.js';
import { PendingMessageKind, type ChatState, type Message } from './state/sessionState.js';
import { PendingMessageKind, type ChatState, type Message, type PendingMessage } from './state/sessionState.js';

export type AgentSessionMessageState =
| { readonly status: 'pending'; readonly kind: PendingMessageKind; readonly message: Message }
| { readonly status: 'processing'; readonly message: Message }
| { readonly status: 'completed'; readonly message: Message };

/** Lists pending steering once, preferring the mixed queue over its legacy mirror. */
export function getPendingSteeringMessages(state: Pick<ChatState, 'steeringMessage' | 'queuedMessages'>): readonly PendingMessage[] {
const steering = state.queuedMessages?.filter(message => message.kind === PendingMessageKind.Steering) ?? [];
const legacy = state.steeringMessage;
return legacy && !state.queuedMessages?.some(message => message.id === legacy.id) ? [legacy, ...steering] : steering;
}

/** Finds an inter-session message wherever it currently lives in a chat. */
export function findAgentSessionMessage(state: ChatState | undefined, messageId: string): AgentSessionMessageState | undefined {
if (!state) {
return undefined;
}
if (state.steeringMessage?.id === messageId) {
return { status: 'pending', kind: PendingMessageKind.Steering, message: state.steeringMessage.message };
const steering = getPendingSteeringMessages(state).find(message => message.id === messageId);
if (steering) {
return { status: 'pending', kind: PendingMessageKind.Steering, message: steering.message };
}
const queued = state.queuedMessages?.find(message => message.id === messageId);
if (queued) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1 @@
dbbd1217
5d542f9e
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,10 @@ export interface ChatTurnStartedAction {
startedAt: string;
/** The new message */
message: Message;
/** If this turn was auto-started from a queued message, the ID of that message */
/**
* If this turn consumes a pending message, its ID. Removes the matching
* entry from {@link ChatState.queuedMessages}.
*/
queuedMessageId?: string;
/**
* Additional provider-specific metadata for this action.
Expand Down Expand Up @@ -749,11 +752,12 @@ export interface ChatTurnsLoadedAction {
/**
* A pending message was set (upsert semantics: creates or replaces).
*
* For steering messages, this always replaces the single steering message.
* For queued messages, if a message with the given `id` already exists it is
* updated in place; otherwise it is appended to the queue. If the chat is
* idle when a queued message is set, the server SHOULD immediately consume it
* and start a new turn.
* Both kinds are stored in {@link ChatState.queuedMessages}, including this
* action's `kind`. A new `id` appends an entry; an existing `id` replaces its
* message and kind in place.
*
* If the chat is idle when a queued message is set, the server SHOULD
* immediately consume it and start a new turn.
*
* A client is only allowed to send {@link MessageKind.User} messages.
*
Expand All @@ -778,6 +782,10 @@ export interface ChatPendingMessageSetAction {
* it consumes a message (e.g. starting a turn from a queued message or
* injecting a steering message into the current turn).
*
* Removes an entry from {@link ChatState.queuedMessages} only when both `id`
* and effective kind match. Unknown IDs and mismatched kinds are
* no-ops.
*
* @category Chat Actions
* @version 1
* @clientDispatchable
Expand All @@ -791,21 +799,22 @@ export interface ChatPendingMessageRemovedAction {
}

/**
* Reorder the queued messages.
* Reorder the entire shared {@link ChatState.queuedMessages} list.
*
* The `order` array contains the IDs of queued messages in their new
* desired order. IDs not present in the current queue are ignored.
* Queued messages whose IDs are absent from `order` are appended at
* The `order` array contains pending message IDs of either kind in their new
* desired raw order. Unknown and duplicate IDs are ignored.
* Messages whose IDs are absent from `order` are appended at
* the end in their original relative order (so a client with a stale
* view of the queue never silently drops messages).
* view of the list never silently drops messages). UIs SHOULD still group
* steering messages first, independently of this raw order.
*
* @category Chat Actions
* @version 1
* @clientDispatchable
*/
export interface ChatQueuedMessagesReorderedAction {
type: ActionType.ChatQueuedMessagesReordered;
/** Queued message IDs in the desired order */
/** Pending message IDs in the desired raw order */
order: string[];
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,7 @@ export function chatReducer(state: ChatState, action: ChatAction, log?: (msg: st
};

// If this turn was auto-started from a pending message, remove it
if (action.queuedMessageId) {
if (action.queuedMessageId !== undefined) {
if (next.steeringMessage?.id === action.queuedMessageId) {
next = { ...next, steeringMessage: undefined };
}
Expand Down Expand Up @@ -878,35 +878,37 @@ export function chatReducer(state: ChatState, action: ChatAction, log?: (msg: st
// ── Pending Messages ──────────────────────────────────────────────────

case ActionType.ChatPendingMessageSet: {
const entry: PendingMessage = { id: action.id, message: action.message };
if (action.kind === PendingMessageKind.Steering) {
return { ...state, steeringMessage: entry };
}
const entry: PendingMessage = { id: action.id, kind: action.kind, message: action.message };
const existing = state.queuedMessages ?? [];
const idx = existing.findIndex(m => m.id === action.id);
if (idx >= 0) {
const updated = [...existing];
updated[idx] = entry;
return { ...state, queuedMessages: updated };
const next = existing.slice();
if (idx < 0) {
next.push(entry);
} else {
next[idx] = entry;
}
return { ...state, queuedMessages: [...existing, entry] };
const nextState = { ...state, queuedMessages: next };
if (action.kind === PendingMessageKind.Steering) {
nextState.steeringMessage = entry;
} else if (state.steeringMessage?.id === action.id) {
nextState.steeringMessage = undefined;
}
return nextState;
}

case ActionType.ChatPendingMessageRemoved: {
if (action.kind === PendingMessageKind.Steering) {
if (!state.steeringMessage || state.steeringMessage.id !== action.id) {
return state;
}
return { ...state, steeringMessage: undefined };
let next = state;
if (action.kind === PendingMessageKind.Steering && state.steeringMessage?.id === action.id) {
next = { ...next, steeringMessage: undefined };
}
const existing = state.queuedMessages;
if (!existing) {
return state;
const existing = state.queuedMessages ?? [];
const idx = existing.findIndex(m => m.id === action.id && (m.kind ?? PendingMessageKind.Queued) === action.kind);
if (idx < 0) {
return next;
}
const filtered = existing.filter(m => m.id !== action.id);
return filtered.length === existing.length
? state
: { ...state, queuedMessages: filtered.length > 0 ? filtered : undefined };
const queuedMessages = existing.slice();
queuedMessages.splice(idx, 1);
return { ...next, queuedMessages: queuedMessages.length > 0 ? queuedMessages : undefined };
}

case ActionType.ChatQueuedMessagesReordered: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,9 +131,20 @@ export interface ChatState {
turnsNextCursor?: string;
/** Currently in-progress turn */
activeTurn?: ActiveTurn;
/** Message to inject into the current turn at a convenient point */
/**
* Legacy steering input retained only when reading old snapshots.
*
* @deprecated Use {@link queuedMessages} with {@link PendingMessage.kind}.
*/
steeringMessage?: PendingMessage;
/** Messages to send automatically as new turns after the current turn finishes */
/**
* Pending messages of both kinds, in host acceptance order unless explicitly
* reordered.
*
* This raw order is not consumption priority. UIs SHOULD group steering
* messages before queued messages, preserving relative order within each
* group without changing the underlying array.
*/
queuedMessages?: PendingMessage[];
/**
* The user's in-progress draft input for this chat — the message they are
Expand Down Expand Up @@ -432,7 +443,11 @@ export const enum PendingMessageKind {
export interface PendingMessage {
/** Unique identifier for this pending message */
id: string;
/** The message that will start the next turn */
/**
* Delivery kind, defaults to {@link PendingMessageKind.Queued}.
*/
kind?: PendingMessageKind;
/** The queued or steering message */
message: Message;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ import type { URI } from '../common/state.js';
* AHP does not redeclare the OTLP type system; clients SHOULD use an
* OpenTelemetry SDK or schema to parse it.
*
* Like all stateless-channel notifications, this is ephemeral: it is not
* replayed on reconnect. Subscribers receive only batches emitted after
* Like other protocol notifications, this is not stored in the channel's
* state and is not replayed on reconnect. Subscribers receive only batches emitted after
* their `subscribe` succeeds.
*
* @category Telemetry Notifications
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

// allow-any-unicode-comment-file
// DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts

import type { URI } from '../common/state.js';
import type { BaseParams } from '../common/commands.js';

/**
* Opens a TCP connection from the host to a destination on the host's network.
*
* The client chooses the channel URI. This command does not subscribe: after it
* succeeds, the client subscribes to the channel with windowed delivery. The
* host MUST NOT send data for the channel before that subscription, and MUST
* limit how much destination data it buffers and how long it keeps a
* connection that is never subscribed to.
*
* The client MUST NOT send this command unless `InitializeResult.tcpConnections`
* is present. If the channel URI is already in use, the host returns
* `AlreadyExists` and MUST NOT replace the existing connection. If the host
* cannot open the connection, it returns `TcpConnectionOpenFailed`.
*
* @category TCP Commands
* @method createTcpConnection
* @direction Client → Server
* @messageType Request
* @version 1
* @stability 1.0
* @see {@link /specification/tcp-channel | TCP Channel}
*/
export interface CreateTcpConnectionParams extends BaseParams {
/** The client-chosen URI for the new channel, for example `ahp-tcp:/<uuid>`. */
channel: URI;
/** A DNS name or IP address, resolved on the host's network. Not a URL. */
host: string;
/**
* The destination port.
* @integer
* @minimum 1
* @maximum 65535
*/
port: number;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

// allow-any-unicode-comment-file
// DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts

import type { URI } from '../common/state.js';

/**
* Bytes in the sender's direction: from the client to the destination, or from
* the destination to the client.
*
* The receiver writes the decoded bytes in the order received. The boundaries
* between `tcp/data` notifications have no meaning. Data is not sent again
* after the AHP connection is lost.
*
* @category TCP Notifications
* @method tcp/data
* @direction Both
* @messageType Notification
* @version 1
* @stability 1.0
* @see {@link /specification/tcp-channel | TCP Channel}
*/
export interface TcpDataParams {
/** The URI of the TCP channel. */
channel: URI;
/**
* The bytes, as padded RFC 4648 base64 without whitespace.
* @minLength 1
*/
data: string;
}

/**
* Ends the sender's direction after the data that it has already sent, like a
* TCP FIN. The other direction can continue. The sender MUST NOT send more
* `tcp/data` after `tcp/eof`.
*
* This notification counts against the window, so the sender may have to wait
* for credit before sending it. A receiver MUST release `tcp/data` as it writes
* the bytes, without waiting for `tcp/eof`.
*
* @category TCP Notifications
* @method tcp/eof
* @direction Both
* @messageType Notification
* @version 1
* @stability 1.0
* @see {@link /specification/tcp-channel | TCP Channel}
*/
export interface TcpEofParams {
/** The URI of the TCP channel. */
channel: URI;
}

/**
* Aborts the connection in both directions, like a TCP RST.
*
* The sender discards data that it has not yet sent. The receiver discards data
* that it has not yet written, and closes its own side of the connection. The
* host sends `tcp/reset` when the destination connection fails or is reset, or
* when the client sends invalid data. The client sends it to abort the
* connection without waiting for buffered data to be written. After `tcp/reset`,
* neither peer sends more data on the channel.
*
* Unlike `tcp/data` and `tcp/eof`, this notification does not count against the
* window, so it can be sent when the window is full.
*
* @category TCP Notifications
* @method tcp/reset
* @windowExempt
* @direction Both
* @messageType Notification
* @version 1
* @stability 1.0
* @see {@link /specification/tcp-channel | TCP Channel}
*/
export interface TcpResetParams {
/** The URI of the TCP channel. */
channel: URI;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

// allow-any-unicode-comment-file
// DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts

/**
* Present when the host supports `createTcpConnection`.
*
* A client creates a connection with `createTcpConnection`, then subscribes to
* its channel with windowed delivery. A TCP channel's state is an
* {@link EmptyState}; its data is sent in `tcp/data` and `tcp/eof`
* notifications. TCP connections do not survive the loss of the AHP connection.
*
* @category TCP Types
* @stability 1.0
* @see {@link /specification/tcp-channel | TCP Channel}
*/
export interface TcpConnectionsCapability { }
Loading
Loading