sessions: add send_message and get_session_context tools (#325139)

* Add send_message server tool

Adds a send_message tool that starts a new turn on an existing session or chat,
reusing the same startPrompt path create_session/create_chat use to deliver their
first message. Accepts a list_sessions URI or an agent-host-session:// link; a
create_chat link (with ?chat=) targets that specific peer chat, otherwise the
session's default chat. Requires confirmation, refuses messaging the current chat
channel (self-loop guard), and has a process-wide fan-out backstop. Renders the
same 'Open Session' pill (conversation icon when chat-scoped) and is unpinned from
the thinking group like the other session tools.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add isSendMessageTool unit test for prefix matching

Addresses PR review: cover the bare and mcp__server__-prefixed name paths for
isSendMessageTool, mirroring the existing isCreateSessionTool/isCreateChatTool
tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* sessions: add get_session_context tool + list_sessions single-session lookup (#325158)

Add get_session_context tool and list_sessions single-session lookup

Adds a read-only get_session_context server tool that returns a compacted
transcript of an existing session/chat's recent turns, read directly from live
chat state. Three fidelity levels: summary (per-turn message + short reply
gist), digest (full reply text + tool-call names), full (+ tool-call inputs);
bounded by transcriptLimit (default 10, max 50) with hasMoreHistory/truncated
flags and the in-progress turn included. Deterministic only, no plan/todo
heuristics. Targets a session's default chat, or a specific peer chat via a
create_chat open link.

Also extends list_sessions with an optional session argument for a direct
single-session lookup by URI / open link (bypassing the other filters), so
session metadata stays with list_sessions while get_session_context focuses
purely on conversation data.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Sandeep Somavarapu
2026-07-09 19:56:22 +02:00
committed by GitHub
co-authored by Copilot
parent cf92f9efff
commit 800753c844
8 changed files with 521 additions and 21 deletions
@@ -24,6 +24,9 @@ export const CREATE_SESSION_TOOL_NAME = 'create_session';
/** Name of the `create_chat` server tool. */
export const CREATE_CHAT_TOOL_NAME = 'create_chat';
/** Name of the `send_message` server tool. */
export const SEND_MESSAGE_TOOL_NAME = 'send_message';
/**
* Whether {@link toolName} (as seen on a tool call) matches {@link bareName}.
* Accepts the bare name and a transport prefix such as Claude's
@@ -43,6 +46,11 @@ export function isCreateChatTool(toolName: string): boolean {
return matchesToolName(toolName, CREATE_CHAT_TOOL_NAME);
}
/** Whether {@link toolName} refers to the `send_message` server tool. */
export function isSendMessageTool(toolName: string): boolean {
return matchesToolName(toolName, SEND_MESSAGE_TOOL_NAME);
}
/** Builds an {@link AGENT_HOST_SESSION_LINK_SCHEME} link for a backend session URI. */
export function buildOpenSessionLinkUri(backendSession: URI | string, chatId?: string): string {
const provider = AgentSession.provider(backendSession);
+23 -2
View File
@@ -29,7 +29,7 @@ import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } f
import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, ContentEncoding, JSON_RPC_INTERNAL_ERROR, ProtocolError, ResourceChangeType, ResourceType, ResourceWriteMode, type CreateResourceWatchParams, type CreateResourceWatchResult, type DirectoryEntry, type ResourceCopyParams, type ResourceCopyResult, type ResourceDeleteParams, type ResourceDeleteResult, type ResourceListResult, type ResourceMkdirParams, type ResourceMkdirResult, type ResourceMoveParams, type ResourceMoveResult, type ResourceReadResult, type ResourceResolveParams, type ResourceResolveResult, type ResourceWatchState, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../common/state/sessionProtocol.js';
import { ChangesSummary, ChatInteractivity, ChatOriginKind, MessageAttachmentKind, type Message, type MessageAttachment, type MessageResourceAttachment } from '../common/state/protocol/state.js';
import type { ChatPendingMessageSetAction, ChatTurnStartedAction } from '../common/state/protocol/actions.js';
import { ISessionGitHubState, ISessionGitState, MessageKind, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, readSessionSpawnDepth, withSessionSpawnDepth, SessionStatus, ToolCallStatus, ToolResultContentType, AH_META_WORKSPACELESS_DB_KEY, buildDefaultChatUri, buildResourceWatchChannelUri, buildSubagentChatUri, buildSubagentSessionUriPrefix, hostBuildInfoFromProduct, isAhpChatChannel, isSubagentSession, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSubagentSessionUri, readSessionGitState, readSessionWorkspaceless, withSessionGitHubState, withSessionGitState, withSessionWorkspaceless, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js';
import { ISessionGitHubState, ISessionGitState, MessageKind, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, readSessionSpawnDepth, withSessionSpawnDepth, SessionStatus, ToolCallStatus, ToolResultContentType, AH_META_WORKSPACELESS_DB_KEY, buildChatUri, buildDefaultChatUri, buildResourceWatchChannelUri, buildSubagentChatUri, buildSubagentSessionUriPrefix, hostBuildInfoFromProduct, isAhpChatChannel, isSubagentSession, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSubagentSessionUri, readSessionGitState, readSessionWorkspaceless, withSessionGitHubState, withSessionGitState, withSessionWorkspaceless, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js';
import { IProductService } from '../../product/common/productService.js';
import { AgentConfigurationService, IAgentConfigurationService } from './agentConfigurationService.js';
import { AgentHostTerminalManager, IAgentHostTerminalManager } from './agentHostTerminalManager.js';
@@ -41,7 +41,7 @@ import { AgentSideEffects } from './agentSideEffects.js';
import { AgentHostLocalTurns } from './agentHostLocalTurns.js';
import { AgentServerToolHost } from './shared/agentServerToolHost.js';
import { buildServerToolGroups } from './shared/serverToolGroups.js';
import { type ISessionServerToolAccessor } from './shared/sessionServerTools.js';
import { type IChatContextSnapshot, type ISessionServerToolAccessor } from './shared/sessionServerTools.js';
import { AgentHostChangesetService } from './agentHostChangesetService.js';
import { AgentHostFileMonitorService, IAgentHostFileMonitorService } from './agentHostFileMonitorService.js';
import { IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE } from '../common/agentHostCheckpointService.js';
@@ -559,6 +559,7 @@ export class AgentService extends Disposable implements IAgentService {
? { ...(options.title !== undefined ? { title: options.title } : {}), ...(options.model !== undefined ? { model: { id: options.model.id } } : {}) }
: undefined),
deleteSession: session => this.disposeSession(session),
getChatContext: (session, chatId) => this._getChatContext(session, chatId),
// Reads the `create_session` spawn depth from a session's `_meta` (0 when absent).
getSessionSpawnDepth: session => readSessionSpawnDepth(this._stateManager.getSessionSummary(session.toString())?._meta),
// Stamps a session's `create_session` spawn depth into its `_meta` (merging existing keys).
@@ -581,6 +582,26 @@ export class AgentService extends Disposable implements IAgentService {
this._sideEffects.handleAction(chat.toString(), action);
}
/**
* Reads a point-in-time snapshot of a session's chat conversation for the
* `get_session_context` server tool. Targets the session's default chat, or a
* specific peer chat when `chatId` is provided. Returns `undefined` when no
* live conversation state exists (e.g. a cold/unsubscribed session).
*/
private _getChatContext(session: URI, chatId?: string): IChatContextSnapshot | undefined {
const chatState = chatId
? this._stateManager.getChatState(buildChatUri(session.toString(), chatId))
: this._stateManager.getDefaultChatState(session.toString());
if (!chatState) {
return undefined;
}
return {
turns: chatState.turns,
...(chatState.activeTurn ? { activeTurn: { message: chatState.activeTurn.message, responseParts: chatState.activeTurn.responseParts } } : {}),
hasMoreHistory: !!chatState.turnsNextCursor,
};
}
async listSessions(): Promise<IAgentSessionMetadata[]> {
this._logService.trace('[AgentService] listSessions called');
const results = await Promise.all(
@@ -8,8 +8,8 @@ import type { Mutable } from '../../../../base/common/types.js';
import { localize } from '../../../../nls.js';
import type { IAgentCreateSessionConfig, IAgentModelInfo, IAgentSessionMetadata } from '../../common/agentService.js';
import { SessionStatus } from '../../common/state/protocol/channels-session/state.js';
import { buildChatUri, buildDefaultChatUri, parseChatUri, readSessionGitState, readSessionGitHubState, type ToolDefinition, type StringOrMarkdown, type URI as ProtocolURI } from '../../common/state/sessionState.js';
import { buildOpenSessionLinkUri, CREATE_CHAT_TOOL_NAME, CREATE_SESSION_TOOL_NAME, parseOpenSessionLinkUri } from '../../common/openSessionLink.js';
import { buildChatUri, buildDefaultChatUri, parseChatUri, readSessionGitState, readSessionGitHubState, ResponsePartKind, ToolCallStatus, TurnState, type Message, type ResponsePart, type ToolCallState, type ToolDefinition, type StringOrMarkdown, type Turn, type URI as ProtocolURI } from '../../common/state/sessionState.js';
import { buildOpenSessionLinkUri, CREATE_CHAT_TOOL_NAME, CREATE_SESSION_TOOL_NAME, parseOpenSessionLinkChatId, parseOpenSessionLinkUri, SEND_MESSAGE_TOOL_NAME } from '../../common/openSessionLink.js';
import { generateUuid } from '../../../../base/common/uuid.js';
import type { AgentHostStateManager } from '../agentHostStateManager.js';
import type { IServerToolDisplay, IServerToolDisplayResult, IServerToolGroup } from './agentServerToolHost.js';
@@ -18,6 +18,8 @@ export const listSessionsToolName = 'list_sessions';
export const getCurrentSessionToolName = 'get_current_session';
export const createSessionToolName = CREATE_SESSION_TOOL_NAME;
export const createChatToolName = CREATE_CHAT_TOOL_NAME;
export const sendMessageToolName = SEND_MESSAGE_TOOL_NAME;
export const getSessionContextToolName = 'get_session_context';
export const deleteSessionToolName = 'delete_session';
/**
@@ -33,7 +35,10 @@ const maxSessionSpawnDepth = 3;
const maxCreatedSessions = 25;
const maxCreatedChats = 25;
const sessionConfirmationToolNames: ReadonlySet<string> = new Set([createSessionToolName, createChatToolName, deleteSessionToolName]);
/** Process-wide backstop against runaway `send_message` fan-out. */
const maxSentMessages = 50;
const sessionConfirmationToolNames: ReadonlySet<string> = new Set([createSessionToolName, createChatToolName, sendMessageToolName, deleteSessionToolName]);
/** Whether the given session server tool requires user confirmation before it runs. */
export function sessionToolRequiresConfirmation(toolName: string): boolean {
@@ -45,6 +50,7 @@ const listSessionsStatusValues = ['idle', 'inProgress', 'inputNeeded', 'error',
const listSessionsInputSchema: ToolDefinition['inputSchema'] = {
type: 'object',
properties: {
session: { type: 'string', description: 'Return only the session with this URI or `agent-host-session://` link (a direct lookup that ignores the other filters). Use this to fetch one known session\'s metadata.' },
status: {
type: 'array',
items: { type: 'string', enum: [...listSessionsStatusValues] },
@@ -94,12 +100,37 @@ const deleteSessionInputSchema: ToolDefinition['inputSchema'] = {
required: ['session'],
};
const sendMessageInputSchema: ToolDefinition['inputSchema'] = {
type: 'object',
properties: {
session: { type: 'string', description: 'The session or chat to message: a session URI from `list_sessions`, or an `agent-host-session://` link (from `create_session`/`create_chat`; a `create_chat` link targets that specific chat).' },
message: { type: 'string', description: 'The message to send.' },
},
required: ['session', 'message'],
};
const sessionContextDetailValues = ['summary', 'digest', 'full'] as const;
const getSessionContextInputSchema: ToolDefinition['inputSchema'] = {
type: 'object',
properties: {
session: { type: 'string', description: 'The session or chat to read: a session URI from `list_sessions`, or an `agent-host-session://` link (a `create_chat` link targets that specific chat).' },
detail: {
type: 'string',
enum: [...sessionContextDetailValues],
description: 'How much conversation detail to return. `summary` (default): status and a short per-turn gist (the message plus a compact snippet of the reply). `digest`: adds the full assistant reply text and tool-call names. `full`: adds tool-call inputs. Higher levels return more tokens.',
},
transcriptLimit: { type: 'number', description: 'Maximum number of most-recent turns to include. Defaults to 10; capped at 50.' },
},
required: ['session'],
};
/** Protocol tool definitions for the session-management server tools. */
export const sessionServerToolDefinitions: ToolDefinition[] = [
{
name: listSessionsToolName,
title: 'List Sessions',
description: 'List sessions and their compact metadata (status, activity, working directory, project, worktree changes, git/GitHub info, timestamps). By default archived sessions are omitted. Optionally filter by `status`, `workspace`, `withChanges`, `unread`, `withPullRequest`, `includeArchived`, `createdAfter`, or `createdBefore`.',
description: 'List sessions and their compact metadata (status, activity, working directory, project, worktree changes, git/GitHub info, timestamps). Pass `session` to fetch a single known session by URI. By default archived sessions are omitted. Optionally filter by `status`, `workspace`, `withChanges`, `unread`, `withPullRequest`, `includeArchived`, `createdAfter`, or `createdBefore`.',
inputSchema: listSessionsInputSchema,
annotations: { readOnlyHint: true },
},
@@ -124,6 +155,20 @@ export const sessionServerToolDefinitions: ToolDefinition[] = [
inputSchema: createChatInputSchema,
annotations: { readOnlyHint: false },
},
{
name: sendMessageToolName,
title: 'Send Message',
description: 'Send a message to an existing session or chat, starting a new turn there. Provide a session URI from `list_sessions` or an `agent-host-session://` link (a `create_chat` link targets that specific chat). The message is delivered asynchronously — this tool does not wait for or return the reply. The UI shows a confirmation with a button to open the target, so reply with a single short sentence and do NOT print the URL or tell the user to click a button.',
inputSchema: sendMessageInputSchema,
annotations: { readOnlyHint: false },
},
{
name: getSessionContextToolName,
title: 'Get Session Context',
description: 'Read the recent conversation of an existing session or chat: a compacted transcript of its turns (messages, replies, and tool calls). Use this to see what a session you created is doing, or to gather context before sending it a message. Returns a compacted summary by default (`detail: "summary"`); request `digest` or `full` for more detail. For session metadata (status, working directory, changes, …) use `list_sessions` with the `session` argument.',
inputSchema: getSessionContextInputSchema,
annotations: { readOnlyHint: true },
},
{
name: deleteSessionToolName,
title: 'Delete Session',
@@ -159,12 +204,24 @@ export interface ISessionServerToolAccessor {
readonly startPrompt: (session: URI, chat: URI, prompt: string) => Promise<void>;
readonly createChat: (session: URI, chat: URI, options?: { title?: string; model?: IAgentModelInfo }) => Promise<void>;
readonly deleteSession: (session: URI) => Promise<void>;
/** Reads a point-in-time snapshot of a session's chat conversation (default chat, or a specific chat by id). */
readonly getChatContext: (session: URI, chatId?: string) => IChatContextSnapshot | undefined;
/** The spawn depth of a session (0 for a user/top-level session, N for one created N levels deep by `create_session`). */
readonly getSessionSpawnDepth: (session: URI) => number;
/** Records the spawn depth of a freshly-created session so its own `create_session` calls can enforce the recursion limit. */
readonly setSessionSpawnDepth: (session: URI, depth: number) => void;
}
/** Point-in-time snapshot of a chat's conversation, read from the host state. */
export interface IChatContextSnapshot {
/** Completed turns, oldest first. */
readonly turns: readonly Turn[];
/** The in-progress turn, if the chat is mid-response. */
readonly activeTurn?: Pick<Turn, 'message' | 'responseParts'>;
/** `true` when older completed turns exist beyond the in-memory window. */
readonly hasMoreHistory: boolean;
}
interface ISerializedGitState {
readonly branch?: string;
readonly baseBranch?: string;
@@ -296,6 +353,8 @@ function describeSessionStatus(status: SessionStatus): string {
/** Filters accepted by `list_sessions` to narrow the returned set. */
export interface IListSessionsArgs {
/** Direct lookup: return only the session with this URI / open link, ignoring all other filters. */
readonly session?: string;
readonly status?: ReadonlySet<string>;
readonly workspace?: string;
readonly withChanges?: boolean;
@@ -334,7 +393,7 @@ function getOptionalTimestamp(value: unknown, field: string, toolName: string):
/** Validates and normalizes the optional `list_sessions` filter arguments. */
export function getListSessionsArgs(rawArgs: unknown): IListSessionsArgs {
const args = (rawArgs ?? {}) as { status?: unknown; workspace?: unknown; withChanges?: unknown; unread?: unknown; withPullRequest?: unknown; includeArchived?: unknown; createdAfter?: unknown; createdBefore?: unknown };
const args = (rawArgs ?? {}) as { session?: unknown; status?: unknown; workspace?: unknown; withChanges?: unknown; unread?: unknown; withPullRequest?: unknown; includeArchived?: unknown; createdAfter?: unknown; createdBefore?: unknown };
let status: Set<string> | undefined;
if (args.status !== undefined) {
@@ -349,6 +408,7 @@ export function getListSessionsArgs(rawArgs: unknown): IListSessionsArgs {
}
return {
session: getOptionalString(args.session, 'session', listSessionsToolName),
status,
workspace: getOptionalString(args.workspace, 'workspace', listSessionsToolName),
withChanges: getOptionalBoolean(args.withChanges, 'withChanges', listSessionsToolName),
@@ -386,6 +446,12 @@ function sessionMatchesWorkspace(session: IAgentSessionMetadata, workspace: stri
/** Applies the {@link IListSessionsArgs} filters to a set of sessions. */
export function filterSessions(sessions: readonly IAgentSessionMetadata[], args: IListSessionsArgs): readonly IAgentSessionMetadata[] {
// A direct `session` lookup returns just that session, bypassing the other
// filters (including the default archived exclusion).
if (args.session !== undefined) {
const target = parseOpenSessionLinkUri(args.session)?.toString() ?? args.session;
return sessions.filter(session => session.session.toString() === target);
}
return sessions.filter(session => {
if (args.status) {
const names = session.status !== undefined ? describeSessionStatus(session.status).split(',') : [];
@@ -591,6 +657,245 @@ export function formatCreateChatResult(result: ICreateChatResult): string {
return `Chat created (${result.openLink}). Reply with one short sentence confirming the chat was created; do not print the URL or mention a button.`;
}
interface ISendMessageArgs {
readonly session?: unknown;
readonly message?: unknown;
}
export interface IResolvedSendMessageArgs {
/** The owning backend session URI of the target chat. */
readonly session: URI;
/** The chat channel to deliver the message on (default chat, or a specific chat when the link carried one). */
readonly chat: URI;
/** The chat id when a specific chat was targeted (from a `create_chat` link). */
readonly chatId?: string;
readonly message: string;
}
/**
* Validates and resolves send-message arguments. When the `session` input is a
* `create_chat` open link (carrying a chat id), the message is targeted at that
* specific chat rather than the session's default chat.
*/
export function getSendMessageArgs(rawArgs: unknown, sessions: readonly IAgentSessionMetadata[]): IResolvedSendMessageArgs {
const args = (rawArgs ?? {}) as ISendMessageArgs;
const message = getRequiredString(args.message, 'message', sendMessageToolName);
const sessionInput = getRequiredString(args.session, 'session', sendMessageToolName);
const session = resolveKnownSession(sessionInput, sessions);
if (!session) {
throw new Error(`Invalid ${sendMessageToolName} input: session must match the URI of a known session (see list_sessions).`);
}
const chatId = parseOpenSessionLinkChatId(sessionInput);
const chat = URI.parse(chatId ? buildChatUri(session.toString(), chatId) : buildDefaultChatUri(session.toString()));
return { session, chat, message, ...(chatId !== undefined ? { chatId } : {}) };
}
/**
* Sends a message to an existing session/chat, starting a new turn there.
* Refuses to target {@link currentChannel} (the chat channel the tool runs on)
* to avoid a session trivially messaging itself in a loop.
*/
export async function applySendMessageTool(accessor: ISessionServerToolAccessor, rawArgs: unknown, currentChannel?: ProtocolURI): Promise<string> {
const sessions = await accessor.listSessions();
const { session, chat, chatId, message } = getSendMessageArgs(rawArgs, sessions);
if (currentChannel && chat.toString() === URI.parse(currentChannel).toString()) {
throw new Error(`Invalid ${sendMessageToolName} input: refusing to send a message to the current chat.`);
}
await accessor.startPrompt(session, chat, message);
return formatSendMessageResult(buildOpenSessionLinkUri(session, chatId));
}
/** Builds the model-facing `send_message` result. */
export function formatSendMessageResult(openLink: string): string {
return `Message sent (${openLink}). Reply with one short sentence confirming the message was sent; do not print the URL or mention a button.`;
}
// --- get_session_context -----------------------------------------------------
type SessionContextDetail = (typeof sessionContextDetailValues)[number];
const defaultTranscriptLimit = 10;
const maxTranscriptLimit = 50;
/** Per-detail truncation caps (characters); a value of 0 omits the field. */
const contextCaps: Record<SessionContextDetail, { user: number; assistant: number; toolInput: number }> = {
// `summary` still carries a short assistant gist per turn so the reader sees
// what each turn actually did, not just what was asked.
summary: { user: 160, assistant: 140, toolInput: 0 },
digest: { user: 300, assistant: 800, toolInput: 0 },
full: { user: 1000, assistant: 2000, toolInput: 200 },
};
interface ISessionContextArgs {
readonly session?: unknown;
readonly detail?: unknown;
readonly transcriptLimit?: unknown;
}
export interface IResolvedSessionContextArgs {
readonly session: URI;
readonly chatId?: string;
readonly detail: SessionContextDetail;
readonly transcriptLimit: number;
}
/** Validates and resolves get-session-context arguments against the known sessions. */
export function getSessionContextArgs(rawArgs: unknown, sessions: readonly IAgentSessionMetadata[]): IResolvedSessionContextArgs {
const args = (rawArgs ?? {}) as ISessionContextArgs;
const sessionInput = getRequiredString(args.session, 'session', getSessionContextToolName);
const session = resolveKnownSession(sessionInput, sessions);
if (!session) {
throw new Error(`Invalid ${getSessionContextToolName} input: session must match the URI of a known session (see list_sessions).`);
}
let detail: SessionContextDetail = 'summary';
if (args.detail !== undefined) {
if (typeof args.detail !== 'string' || !(sessionContextDetailValues as readonly string[]).includes(args.detail)) {
throw new Error(`Invalid ${getSessionContextToolName} input: detail must be one of ${sessionContextDetailValues.join(', ')}.`);
}
detail = args.detail as SessionContextDetail;
}
let transcriptLimit = defaultTranscriptLimit;
if (args.transcriptLimit !== undefined) {
if (typeof args.transcriptLimit !== 'number' || !Number.isFinite(args.transcriptLimit) || args.transcriptLimit < 1) {
throw new Error(`Invalid ${getSessionContextToolName} input: transcriptLimit must be a positive number.`);
}
transcriptLimit = Math.min(Math.floor(args.transcriptLimit), maxTranscriptLimit);
}
const chatId = parseOpenSessionLinkChatId(sessionInput);
return { session, detail, transcriptLimit, ...(chatId !== undefined ? { chatId } : {}) };
}
/** Truncates {@link text} to {@link max} characters, appending an ellipsis when cut. */
function truncateText(text: string, max: number): { text: string; truncated: boolean } {
const trimmed = text.trim();
if (trimmed.length <= max) {
return { text: trimmed, truncated: false };
}
return { text: `${trimmed.slice(0, Math.max(0, max - 1))}`, truncated: true };
}
/** Reads the tool-call parts of a turn, newest-emitted last. */
function toolCallsOf(parts: readonly ResponsePart[]): ToolCallState[] {
return parts.filter((p): p is Extract<ResponsePart, { kind: ResponsePartKind.ToolCall }> => p.kind === ResponsePartKind.ToolCall).map(p => p.toolCall);
}
/** Concatenated markdown text of a turn's response, in stream order. */
function assistantTextOf(parts: readonly ResponsePart[]): string {
return parts.filter((p): p is Extract<ResponsePart, { kind: ResponsePartKind.Markdown }> => p.kind === ResponsePartKind.Markdown).map(p => p.content).join('').trim();
}
/** Reads a tool call's JSON input string, which is absent while still streaming. */
function readToolInput(tc: ToolCallState): string | undefined {
return tc.status === ToolCallStatus.Streaming ? undefined : tc.toolInput;
}
interface ISerializedContextTurn {
readonly turn: number;
readonly state: string;
readonly user?: string;
readonly assistant?: string;
readonly toolCalls?: readonly (string | { readonly name: string; readonly input?: string })[];
}
/** Maps a {@link TurnState} (or the in-progress active turn) to a display string. */
function describeTurnState(state: TurnState | 'inProgress'): string {
switch (state) {
case TurnState.Complete: return 'complete';
case TurnState.Cancelled: return 'cancelled';
case TurnState.Error: return 'error';
default: return 'inProgress';
}
}
interface ISerializedSessionContext {
readonly session: string;
readonly openLink: string;
readonly detail: SessionContextDetail;
readonly transcript: readonly ISerializedContextTurn[];
readonly hasMoreHistory: boolean;
/** `true` when turns were dropped from the window or any field was shortened. */
readonly truncated: boolean;
}
/** Builds the compacted, model-facing session-context payload from a snapshot. */
export function serializeSessionContext(session: URI, chatId: string | undefined, snapshot: IChatContextSnapshot, detail: SessionContextDetail, transcriptLimit: number): string {
const caps = contextCaps[detail];
let truncated = false;
const trunc = (text: string, max: number): string | undefined => {
if (max <= 0 || !text) {
return undefined;
}
const result = truncateText(text, max);
truncated = truncated || result.truncated;
return result.text || undefined;
};
const entries: { message: Message; parts: readonly ResponsePart[]; state: TurnState | 'inProgress' }[] =
snapshot.turns.map(t => ({ message: t.message, parts: t.responseParts, state: t.state }));
if (snapshot.activeTurn) {
entries.push({ message: snapshot.activeTurn.message, parts: snapshot.activeTurn.responseParts, state: 'inProgress' });
}
if (entries.length > transcriptLimit) {
truncated = true;
}
const windowStart = Math.max(0, entries.length - transcriptLimit);
const windowed = entries.slice(windowStart);
const transcript: ISerializedContextTurn[] = windowed.map((entry, index): ISerializedContextTurn => {
const user = trunc(entry.message.text, caps.user);
const assistant = trunc(assistantTextOf(entry.parts), caps.assistant);
const toolCalls = toolCallsOf(entry.parts);
let serializedToolCalls: (string | { name: string; input?: string })[] | undefined;
if (detail !== 'summary' && toolCalls.length > 0) {
serializedToolCalls = toolCalls.map(tc => {
if (caps.toolInput > 0) {
const input = trunc(readToolInput(tc) ?? '', caps.toolInput);
return input !== undefined ? { name: tc.toolName, input } : { name: tc.toolName };
}
return tc.toolName;
});
}
return {
turn: windowStart + index + 1,
state: describeTurnState(entry.state),
...(user !== undefined ? { user } : {}),
...(assistant !== undefined ? { assistant } : {}),
...(serializedToolCalls ? { toolCalls: serializedToolCalls } : {}),
};
});
const payload: ISerializedSessionContext = {
session: session.toString(),
openLink: buildOpenSessionLinkUri(session, chatId),
detail,
transcript,
hasMoreHistory: snapshot.hasMoreHistory,
truncated,
};
return JSON.stringify(payload);
}
/** Reads and serializes the context of an existing session/chat. */
export async function applyGetSessionContextTool(accessor: ISessionServerToolAccessor, rawArgs: unknown): Promise<string> {
const sessions = await accessor.listSessions();
const { session, chatId, detail, transcriptLimit } = getSessionContextArgs(rawArgs, sessions);
const snapshot = accessor.getChatContext(session, chatId);
if (!snapshot) {
// No live conversation state (e.g. a cold/unsubscribed session): return the
// identity + an empty transcript. Metadata is available via list_sessions.
return JSON.stringify({
session: session.toString(),
openLink: buildOpenSessionLinkUri(session, chatId),
detail,
transcript: [],
hasMoreHistory: false,
truncated: false,
} satisfies ISerializedSessionContext);
}
return serializeSessionContext(session, chatId, snapshot, detail, transcriptLimit);
}
/** Serializes the current session's metadata + open link as the `get_current_session` result. */
export function serializeCurrentSession(currentSession: URI, sessions: readonly IAgentSessionMetadata[]): string {
const meta = sessions.find(s => s.session.toString() === currentSession.toString());
@@ -673,6 +978,18 @@ function getSessionToolDisplay(toolName: string, _args: unknown, result?: IServe
invocationMessage: localize('toolInvoke.createChat', "Creating chat"),
pastTenseMessage: localize('toolComplete.createChat', "Created chat"),
};
case sendMessageToolName:
return {
displayName: localize('toolName.sendMessage', "Send Message"),
invocationMessage: localize('toolInvoke.sendMessage', "Sending message"),
pastTenseMessage: localize('toolComplete.sendMessage', "Sent message"),
};
case getSessionContextToolName:
return {
displayName: localize('toolName.getSessionContext', "Get Session Context"),
invocationMessage: localize('toolInvoke.getSessionContext', "Reading session context"),
pastTenseMessage: localize('toolComplete.getSessionContext', "Read session context"),
};
case getCurrentSessionToolName:
return {
displayName: localize('toolName.getCurrentSession', "Get Current Session"),
@@ -702,6 +1019,7 @@ function getSessionToolDisplay(toolName: string, _args: unknown, result?: IServe
export function createSessionServerToolGroup(accessor?: ISessionServerToolAccessor): IServerToolGroup {
let createdSessionCount = 0;
let createdChatCount = 0;
let sentMessageCount = 0;
const group: IServerToolGroup = {
definitions: sessionServerToolDefinitions,
requiresConfirmation(toolName: string): boolean {
@@ -735,6 +1053,16 @@ export function createSessionServerToolGroup(accessor?: ISessionServerToolAccess
createdChatCount++;
return formatCreateChatResult(result);
}
case sendMessageToolName: {
if (sentMessageCount >= maxSentMessages) {
throw new Error(`Refusing to send more than ${maxSentMessages} messages from server tools in this process.`);
}
const result = await applySendMessageTool(accessor, rawArgs, sessionUri);
sentMessageCount++;
return result;
}
case getSessionContextToolName:
return applyGetSessionContextTool(accessor, rawArgs);
case deleteSessionToolName:
return applyDeleteSessionTool(accessor, rawArgs, currentSessionUri(sessionUri));
default:
@@ -6,7 +6,7 @@
import assert from 'assert';
import { URI } from '../../../../base/common/uri.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
import { buildOpenSessionLinkUri, isCreateChatTool, isCreateSessionTool, parseOpenSessionLinkChatId, parseOpenSessionLinkUri } from '../../common/openSessionLink.js';
import { buildOpenSessionLinkUri, isCreateChatTool, isCreateSessionTool, isSendMessageTool, parseOpenSessionLinkChatId, parseOpenSessionLinkUri } from '../../common/openSessionLink.js';
suite('openSessionLink', () => {
@@ -24,6 +24,12 @@ suite('openSessionLink', () => {
assert.strictEqual(isCreateChatTool('create_session'), false);
});
test('isSendMessageTool matches bare and mcp-prefixed names', () => {
assert.strictEqual(isSendMessageTool('send_message'), true);
assert.strictEqual(isSendMessageTool('mcp__server__send_message'), true);
assert.strictEqual(isSendMessageTool('create_chat'), false);
});
test('builds a link from a backend session URI', () => {
assert.strictEqual(buildOpenSessionLinkUri('copilotcli:/abc-123'), 'agent-host-session://copilotcli/abc-123');
});
@@ -49,12 +49,16 @@ suite('serverToolGroups display', () => {
current: display('get_current_session'),
create: display('create_session'),
chat: display('create_chat'),
send: display('send_message'),
context: display('get_session_context'),
del: display('delete_session'),
}, {
list: { displayName: 'List Sessions', invocation: 'Checking sessions' },
current: { displayName: 'Get Current Session', invocation: 'Checking current session' },
create: { displayName: 'Create Session', invocation: 'Creating session' },
chat: { displayName: 'Create Chat', invocation: 'Creating chat' },
send: { displayName: 'Send Message', invocation: 'Sending message' },
context: { displayName: 'Get Session Context', invocation: 'Reading session context' },
del: { displayName: 'Delete Session', invocation: 'Deleting session' },
});
});
@@ -10,11 +10,12 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c
import { NullLogService } from '../../../log/common/log.js';
import type { IAgentCreateSessionConfig, IAgentModelInfo, IAgentSessionMetadata } from '../../common/agentService.js';
import { SessionStatus } from '../../common/state/protocol/channels-session/state.js';
import { buildDefaultChatUri, withSessionGitState, withSessionGitHubState } from '../../common/state/sessionState.js';
import { buildChatUri, buildDefaultChatUri, MessageKind, ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, TurnState, withSessionGitState, withSessionGitHubState, type ResponsePart, type ToolCallState, type Turn } from '../../common/state/sessionState.js';
import { AgentHostStateManager } from '../../node/agentHostStateManager.js';
import {
applyCreateChatTool,
applyDeleteSessionTool,
applySendMessageTool,
createChatToolName,
createSessionServerToolGroup,
createSessionToolName,
@@ -23,12 +24,18 @@ import {
getCreateSessionArgs,
getCurrentSessionToolName,
getDeleteSessionArgs,
getSendMessageArgs,
getSessionContextArgs,
serializeSessionContext,
getSessionContextToolName,
filterSessions,
getListSessionsArgs,
listSessionsToolName,
sendMessageToolName,
sessionServerToolDefinitions,
sessionToolRequiresConfirmation,
serializeSessions,
type IChatContextSnapshot,
type ISessionServerToolAccessor,
} from '../../node/shared/sessionServerTools.js';
@@ -52,18 +59,21 @@ suite('SessionServerTools', () => {
startPrompt: overrides?.startPrompt ?? (async (session, chat, prompt) => { overrides?.onPrompt?.(session, chat, prompt); }),
createChat: overrides?.createChat ?? (async (session, chat, options) => { overrides?.onCreateChat?.(session, chat, options); }),
deleteSession: overrides?.deleteSession ?? (async session => { overrides?.onDelete?.(session); }),
getChatContext: overrides?.getChatContext ?? (() => undefined),
getSessionSpawnDepth: overrides?.getSessionSpawnDepth ?? (session => depths.get(session.toString()) ?? 0),
setSessionSpawnDepth: overrides?.setSessionSpawnDepth ?? ((session, depth) => { depths.set(session.toString(), depth); }),
};
}
test('definitions and confirmation', () => {
assert.deepStrictEqual(sessionServerToolDefinitions.map(d => d.name), [listSessionsToolName, getCurrentSessionToolName, createSessionToolName, createChatToolName, deleteSessionToolName]);
assert.deepStrictEqual(sessionServerToolDefinitions.map(d => d.name), [listSessionsToolName, getCurrentSessionToolName, createSessionToolName, createChatToolName, sendMessageToolName, getSessionContextToolName, deleteSessionToolName]);
assert.strictEqual(sessionToolRequiresConfirmation(createSessionToolName), true);
assert.strictEqual(sessionToolRequiresConfirmation(createChatToolName), true);
assert.strictEqual(sessionToolRequiresConfirmation(sendMessageToolName), true);
assert.strictEqual(sessionToolRequiresConfirmation(deleteSessionToolName), true);
assert.strictEqual(sessionToolRequiresConfirmation(listSessionsToolName), false);
assert.strictEqual(sessionToolRequiresConfirmation(getCurrentSessionToolName), false);
assert.strictEqual(sessionToolRequiresConfirmation(getSessionContextToolName), false);
});
test('serializeSessions produces compact metadata', () => {
@@ -202,7 +212,7 @@ suite('SessionServerTools', () => {
});
test('getListSessionsArgs validates filter input', () => {
assert.deepStrictEqual(getListSessionsArgs({}), { status: undefined, workspace: undefined, withChanges: undefined, unread: undefined, withPullRequest: undefined, includeArchived: undefined, createdAfter: undefined, createdBefore: undefined });
assert.deepStrictEqual(getListSessionsArgs({}), { session: undefined, status: undefined, workspace: undefined, withChanges: undefined, unread: undefined, withPullRequest: undefined, includeArchived: undefined, createdAfter: undefined, createdBefore: undefined });
assert.throws(() => getListSessionsArgs({ status: ['bogus'] }), /status/);
assert.throws(() => getListSessionsArgs({ withChanges: 'yes' }), /withChanges/);
assert.throws(() => getListSessionsArgs({ includeArchived: 'no' }), /includeArchived/);
@@ -210,6 +220,24 @@ suite('SessionServerTools', () => {
assert.strictEqual(filterSessions([sessionMeta('s1', SessionStatus.Idle, workspace)], getListSessionsArgs({})).length, 1);
});
test('list_sessions fetches a single session by URI or open link, bypassing other filters', () => {
const archived = { ...sessionMeta('archived', SessionStatus.Idle, workspace), isArchived: true };
const sessions = [sessionMeta('s1', SessionStatus.Idle, workspace), archived];
const ids = (args: object) => filterSessions(sessions, getListSessionsArgs(args)).map(s => s.session.toString());
assert.deepStrictEqual({
byUri: ids({ session: 'copilot:/s1' }),
byLink: ids({ session: 'agent-host-session://copilot/s1' }),
// A direct lookup returns an archived session even though archived are hidden by default.
archivedByUri: ids({ session: 'copilot:/archived' }),
unknown: ids({ session: 'copilot:/nope' }),
}, {
byUri: ['copilot:/s1'],
byLink: ['copilot:/s1'],
archivedByUri: ['copilot:/archived'],
unknown: [],
});
});
test('create_session stamps spawn depth and enforces the recursion depth limit', async () => {
const store = new DisposableStore();
const stateManager = store.add(new AgentHostStateManager(new NullLogService()));
@@ -277,6 +305,106 @@ suite('SessionServerTools', () => {
assert.strictEqual(prompted?.prompt, 'do it');
});
test('send_message targets the default chat / a specific chat, refuses the current chat, and validates', async () => {
const prompts: { session: URI; chat: URI; prompt: string }[] = [];
const accessor = createAccessor({
listSessions: async () => [sessionMeta('s1', SessionStatus.Idle, workspace), sessionMeta('s2', SessionStatus.Idle, workspace)],
onPrompt: (session, chat, prompt) => { prompts.push({ session, chat, prompt }); },
});
const currentChannel = buildDefaultChatUri('copilot:/s1');
// Explicit session -> owning session's default chat.
const toSession = await applySendMessageTool(accessor, { session: 'copilot:/s2', message: 'hi' }, currentChannel);
assert.strictEqual(prompts.at(-1)?.session.toString(), 'copilot:/s2');
assert.strictEqual(prompts.at(-1)?.chat.toString(), buildDefaultChatUri('copilot:/s2'));
assert.strictEqual(prompts.at(-1)?.prompt, 'hi');
assert.ok(toSession.includes('agent-host-session://copilot/s2'));
// A create_chat open link -> that specific chat channel.
await applySendMessageTool(accessor, { session: 'agent-host-session://copilot/s2?chat=c9', message: 'yo' }, currentChannel);
assert.strictEqual(prompts.at(-1)?.chat.toString(), buildChatUri('copilot:/s2', 'c9'));
// Refuses messaging the exact current chat channel (self-loop guard).
await assert.rejects(() => applySendMessageTool(accessor, { session: 'copilot:/s1', message: 'loop' }, currentChannel), /current chat/);
// Unknown session and missing session/message are rejected.
await assert.rejects(() => applySendMessageTool(accessor, { session: 'copilot:/nope', message: 'x' }, currentChannel), /known session/);
assert.throws(() => getSendMessageArgs({ message: 'x' }, []), /session/);
assert.throws(() => getSendMessageArgs({ session: 'copilot:/s2' }, []), /message/);
});
suite('get_session_context', () => {
const toolCall = (toolName: string, input: object): ToolCallState => ({
toolCallId: 't', toolName, displayName: toolName,
invocationMessage: '', toolInput: JSON.stringify(input),
status: ToolCallStatus.Completed, confirmed: ToolCallConfirmationReason.NotNeeded,
success: true, pastTenseMessage: '',
});
const md = (content: string): ResponsePart => ({ kind: ResponsePartKind.Markdown, id: 'm', content });
const toolPart = (tc: ToolCallState): ResponsePart => ({ kind: ResponsePartKind.ToolCall, toolCall: tc });
const turn = (id: string, user: string, parts: ResponsePart[], state = TurnState.Complete): Turn =>
({ id, message: { text: user, origin: { kind: MessageKind.User } }, responseParts: parts, usage: undefined, state });
const snapshot: IChatContextSnapshot = {
turns: [
turn('t1', 'do the thing', [toolPart(toolCall('read_file', { path: 'a.ts' })), md('Working on it.')]),
turn('t2', 'now finish it', [toolPart(toolCall('apply_patch', { patch: '@@' })), md('Here is the result.')]),
],
hasMoreHistory: true,
};
test('summary returns per-turn gists (message + reply snippet), no tool calls', () => {
assert.deepStrictEqual(JSON.parse(serializeSessionContext(URI.parse('copilot:/s1'), undefined, snapshot, 'summary', 10)), {
session: 'copilot:/s1',
openLink: 'agent-host-session://copilot/s1',
detail: 'summary',
transcript: [
{ turn: 1, state: 'complete', user: 'do the thing', assistant: 'Working on it.' },
{ turn: 2, state: 'complete', user: 'now finish it', assistant: 'Here is the result.' },
],
hasMoreHistory: true,
truncated: false,
});
});
test('digest adds assistant text and tool-call names', () => {
const digest = JSON.parse(serializeSessionContext(URI.parse('copilot:/s1'), undefined, snapshot, 'digest', 10));
assert.deepStrictEqual(digest.transcript[0], { turn: 1, state: 'complete', user: 'do the thing', assistant: 'Working on it.', toolCalls: ['read_file'] });
});
test('detail=full targeting a specific chat carries the chat link and tool inputs', () => {
const full = JSON.parse(serializeSessionContext(URI.parse('copilot:/s1'), 'c9', snapshot, 'full', 10));
assert.strictEqual(full.openLink, 'agent-host-session://copilot/s1?chat=c9');
assert.deepStrictEqual(full.transcript[1].toolCalls, [{ name: 'apply_patch', input: '{"patch":"@@"}' }]);
});
test('transcriptLimit drops older turns and flags truncated', () => {
const limited = JSON.parse(serializeSessionContext(URI.parse('copilot:/s1'), undefined, snapshot, 'summary', 1));
assert.deepStrictEqual({ turns: limited.transcript.map((t: { turn: number }) => t.turn), truncated: limited.truncated }, { turns: [2], truncated: true });
});
test('execute reads from the accessor; cold session returns identity + empty transcript', async () => {
const store = new DisposableStore();
const stateManager = store.add(new AgentHostStateManager(new NullLogService()));
const sessions = [sessionMeta('s1', SessionStatus.Idle, workspace)];
const withCtx = createSessionServerToolGroup(createAccessor({ listSessions: async () => sessions, getChatContext: () => snapshot }));
const live = JSON.parse(await withCtx.execute(stateManager, 'copilot:/caller', getSessionContextToolName, { session: 'copilot:/s1' }));
assert.strictEqual(live.transcript.length, 2);
const cold = createSessionServerToolGroup(createAccessor({ listSessions: async () => sessions, getChatContext: () => undefined }));
assert.deepStrictEqual(JSON.parse(await cold.execute(stateManager, 'copilot:/caller', getSessionContextToolName, { session: 'copilot:/s1' })), {
session: 'copilot:/s1', openLink: 'agent-host-session://copilot/s1', detail: 'summary', transcript: [], hasMoreHistory: false, truncated: false,
});
store.dispose();
});
test('getSessionContextArgs validates input', () => {
assert.throws(() => getSessionContextArgs({}, []), /session/);
assert.throws(() => getSessionContextArgs({ session: 'copilot:/nope' }, [sessionMeta('s1', SessionStatus.Idle, workspace)]), /known session/);
assert.throws(() => getSessionContextArgs({ session: 'copilot:/s1', detail: 'huge' }, [sessionMeta('s1', SessionStatus.Idle, workspace)]), /detail/);
assert.strictEqual(getSessionContextArgs({ session: 'copilot:/s1', transcriptLimit: 999 }, [sessionMeta('s1', SessionStatus.Idle, workspace)]).transcriptLimit, 50);
});
});
test('get_current_session returns the current session link + metadata', async () => {
const store = new DisposableStore();
const stateManager = store.add(new AgentHostStateManager(new NullLogService()));
@@ -19,7 +19,7 @@ import { AGENT_HOST_SCHEME, toAgentHostUri } from '../../../../../../platform/ag
import { getAgentFeedbackAttachmentMetadata, isAgentFeedbackAnnotationsAttachment, isAgentFeedbackAttachment } from '../../../../../../platform/agentHost/common/meta/agentFeedbackAttachments.js';
import { getBrowserViewAttachmentMetadata, isBrowserViewAttachment } from '../../../../../../platform/agentHost/common/meta/browserViewAttachments.js';
import { isViewUnreviewedCommentsTool, isAddCommentTool } from '../../../../../../platform/agentHost/common/meta/agentFeedbackAnnotations.js';
import { isCreateChatTool, isCreateSessionTool, parseOpenSessionLinkUri } from '../../../../../../platform/agentHost/common/openSessionLink.js';
import { isCreateChatTool, isCreateSessionTool, isSendMessageTool, parseOpenSessionLinkChatId, parseOpenSessionLinkUri } from '../../../../../../platform/agentHost/common/openSessionLink.js';
import { MessageAttachmentKind, type FileEdit, type MessageAttachment, type StringOrMarkdown, type TextRange } from '../../../../../../platform/agentHost/common/state/protocol/state.js';
import { normalizeFileEdit } from '../../../../../../platform/agentHost/common/fileEditDiff.js';
import product from '../../../../../../platform/product/common/product.js';
@@ -1123,8 +1123,8 @@ function buildSessionCreatedToolData(tc: ToolCallState): IChatSessionCreatedData
if (tc.status !== ToolCallStatus.Completed || !tc.success) {
return undefined;
}
const isChat = isCreateChatTool(tc.toolName);
if (!isCreateSessionTool(tc.toolName) && !isChat) {
const isSend = isSendMessageTool(tc.toolName);
if (!isCreateSessionTool(tc.toolName) && !isCreateChatTool(tc.toolName) && !isSend) {
return undefined;
}
const output = getToolOutputText(tc);
@@ -1134,24 +1134,29 @@ function buildSessionCreatedToolData(tc: ToolCallState): IChatSessionCreatedData
if (!openLink || !backend) {
return undefined;
}
// A chat-scoped link (create_chat, or send_message targeting a specific chat)
// shows the conversation icon; a session-scoped link shows the agent icon.
const isChat = isCreateChatTool(tc.toolName) || (isSend && !!parseOpenSessionLinkChatId(openLink));
const label = createSessionTitleFromArgs(tc.toolInput) ?? (backend.path.replace(/^\//, '') || backend.toString());
return { kind: 'sessionCreated', openLink, label, isChat };
}
/**
* Derives a session title for the "Open Session" button from the `create_session`
* arguments the prompt the session was started with, trimmed to one line.
* Derives a title for the "Open Session" button from a session tool's arguments
* the `prompt` (create_session/create_chat) or `message` (send_message) it was
* started with, trimmed to one line.
*/
function createSessionTitleFromArgs(toolInput: string | undefined): string | undefined {
if (!toolInput) {
return undefined;
}
try {
const args = JSON.parse(toolInput) as { prompt?: unknown };
if (typeof args.prompt !== 'string') {
const args = JSON.parse(toolInput) as { prompt?: unknown; message?: unknown };
const text = typeof args.prompt === 'string' ? args.prompt : (typeof args.message === 'string' ? args.message : undefined);
if (text === undefined) {
return undefined;
}
const firstLine = args.prompt.trim().split('\n')[0].trim();
const firstLine = text.trim().split('\n')[0].trim();
if (!firstLine) {
return undefined;
}
@@ -44,7 +44,7 @@ import { isDark } from '../../../../../platform/theme/common/theme.js';
import { IThemeService } from '../../../../../platform/theme/common/themeService.js';
import { AccessibilitySignal, IAccessibilitySignalService } from '../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js';
import { parseRemoteAgentHostSessionTypeAuthority } from '../../../../../platform/agentHost/common/agentHostSessionType.js';
import { isCreateChatTool, isCreateSessionTool } from '../../../../../platform/agentHost/common/openSessionLink.js';
import { isCreateChatTool, isCreateSessionTool, isSendMessageTool } from '../../../../../platform/agentHost/common/openSessionLink.js';
import { IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js';
import { CodiconActionViewItem } from '../../../notebook/browser/view/cellParts/cellActionView.js';
import { annotateSpecialMarkdownContent, extractSubAgentInvocationIdFromText, hasCodeblockUriTag, hasEditCodeblockUriTag } from '../../common/widget/annotations.js';
@@ -2196,7 +2196,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
// "Open Session" button must stay visible, not hidden inside a collapsed
// thinking group. Keyed on toolId so this holds while the tool streams too
// (before `toolSpecificData` is set on completion).
if ((part.kind === 'toolInvocation' || part.kind === 'toolInvocationSerialized') && (isCreateSessionTool(part.toolId) || isCreateChatTool(part.toolId))) {
if ((part.kind === 'toolInvocation' || part.kind === 'toolInvocationSerialized') && (isCreateSessionTool(part.toolId) || isCreateChatTool(part.toolId) || isSendMessageTool(part.toolId))) {
return false;
}