src/vs/platform/agentHost/node/localCommands/localChatCommand.ts

251 LOC · 240 covered · 11 uncovered · 29 ranges · 1049 concepts · 9 introducers · 506 tests

File neighbourhood

The centred file is linked to every concept that introduces one of its ranges, every test that runs code from the file, and the gray connector concepts standing between those tests and the file's own introducer concepts. Undirected links join concepts to every file where they introduce source and concepts to the tests they introduce; arrows show specialization between the displayed concepts and bridge only concepts omitted from this view. Concept colors match the source ranges below; connector concepts have no source color and are shown in gray.

Focused file, its introducer and connector concepts, their introduced files, and tests that run code from the file

In the embedded map, ordinary wheel input scrolls the page; use the visible controls to zoom and drag to pan. Open the full-screen map for canvas navigation: wheel pans, Ctrl/Command plus wheel zooms, and arrow keys pan when this region is focused. On touch screens, open the full-screen map to pan or pinch. If JavaScript or WebGL is unavailable, use the related-file, concept, and source links on this page.

Graph controls are ready.

Interactive rendering requires JavaScript and WebGL. Use the related-file, concept, and source links on this page while the interactive map is unavailable.

1 > /*--------------------------------------------------------------------------------------------- agentSideEffects.ts ×51
2 > * Copyright (c) Microsoft Corporation. All rights reserved.
3 > * Licensed under the MIT License. See License.txt in the project root for license information.
4 > *--------------------------------------------------------------------------------------------*/
5 >
6 > import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js';
7 > import { StopWatch } from '../../../../base/common/stopwatch.js';
8 > import { ILogService } from '../../../log/common/log.js';
9 > import { ISessionDataService } from '../../common/sessionDataService.js';
10 > import { ActionType, StateAction } from '../../common/state/sessionActions.js';
11 > import { isAhpChatChannel, parseRequiredSessionUriFromChatUri, ResponsePartKind, ToolCallStatus, ToolResultContentType, type ISessionWithDefaultChat, type Turn, type URI as ProtocolURI } from '../../common/state/sessionState.js';
12 > import { AgentHostLocalTurns } from '../agentHostLocalTurns.js';
13 > import { IAgentHostTerminalManager } from '../agentHostTerminalManager.js';
14 > import { AgentHostStateManager } from '../agentHostStateManager.js';
15 > import { persistSessionMetadata } from '../shared/persistSessionMetadata.js';
16 >
17 > /**
18 > * A just-started chat turn offered to the local-command dispatcher before it is
19 > * forwarded to the agent SDK.
20 > */
21 > export interface ILocalChatCommandRequest {
22 > /** The chat channel the turn was started on (default or peer chat). */
23 > readonly turnChannel: ProtocolURI;
24 > /** The turn identifier opened by the reducer for this message. */
25 > readonly turnId: string;
26 > /** The raw user message text. */
27 > readonly text: string;
28 > }
29 >
30 > /**
31 > * The narrow set of agent-host capabilities a {@link ILocalChatCommand} may use
32 > * to fulfil a request. Keeps commands decoupled from `AgentSideEffects`
33 > * internals — they emit response content by dispatching server actions and read
34 > * conversation state, plus the few extra capabilities specific commands need
35 > * (terminal execution, chat rename/persist).
36 > */
37 > export interface ILocalChatCommandContext {
38 > readonly logService: ILogService;
39 > readonly terminalManager: IAgentHostTerminalManager;
40 > /** Dispatch a server-originated action on a channel. */
41 > dispatch(channel: ProtocolURI, action: StateAction): void;
42 > /** Read the merged session/chat state for a session or chat channel. */
43 > getState(channel: ProtocolURI): ISessionWithDefaultChat | undefined;
44 > /** Rename a single chat (independently of the session title). */
45 > updateChatTitle(session: ProtocolURI, chat: ProtocolURI, title: string): void;
46 > /** Persist a session-metadata key/value pair (e.g. a custom title). */
47 > persistSessionFlag(session: ProtocolURI, key: string, value: string): void;
48 > }
49 >
50 > /**
51 > * The outcome of a {@link ILocalChatCommand.tryHandle} that accepted a request:
52 > * the work to perform plus any metadata the dispatcher and its caller need.
53 > */
54 > export interface ILocalChatCommandHandling {
55 > /** Performs the (possibly async) work of the command. */
56 > run(): Promise<void>;
57 > /**
58 > * A provisional title the command suggests for a brand-new session — for
59 > * example a `!command`'s command text. It is surfaced up through the
60 > * {@link AgentHostLocalCommands} dispatcher so the caller can title an
61 > * otherwise-untitled session; a subsequent real request replaces it with a
62 > * generated title. Commands that do not title the session omit this.
63 > */
64 > readonly suggestedTitle?: string;
65 > }
66 >
67 > /**
68 > * A generic, agent-agnostic chat command handled entirely by the agent host
69 > * (never forwarded to the agent SDK) — for example `/rename` or `!command`.
70 > *
71 > * A command decides synchronously whether it applies (so the caller knows
72 > * immediately not to forward the message), then performs its work — emitting
73 > * response parts/tool calls via {@link ILocalChatCommandContext}. The
74 > * {@link AgentHostLocalCommands} dispatcher owns the common tail: completing the
75 > * turn, optionally persisting it as a local turn (so it survives reload and
76 > * anchors fork/truncate), and draining the message queue.
77 > */
78 > export interface ILocalChatCommand extends IDisposable {
79 > /** Stable identifier for logging/telemetry. */
80 > readonly name: string;
81 > /**
82 > * Whether the completed turn should be persisted as a host-injected local
83 > * turn (survives reload; anchors fork/truncate to the preceding concrete
84 > * turn). Most user-visible commands want `true`.
85 > */
86 > readonly recordsLocalTurn: boolean;
87 > /**
88 > * Synchronously decide whether this command handles `request`. Returns an
89 > * {@link ILocalChatCommandHandling} describing the (possibly async) work when
90 > * it does, or `undefined` to decline so the dispatcher tries the next command
91 > * (and ultimately forwards the message to the agent).
92 > */
93 > tryHandle(request: ILocalChatCommandRequest): ILocalChatCommandHandling | undefined;
94 > }
95 >
96 > /** Constructs a {@link ILocalChatCommand} bound to a context. */
97 > export interface ILocalChatCommandCtor {
98 > new(context: ILocalChatCommandContext): ILocalChatCommand;
99 > }
100 >
101 > /**
102 > * Global registry of {@link ILocalChatCommand} constructors. Command modules
103 > * register themselves at load time; {@link AgentHostLocalCommands} instantiates
104 > * all registered commands per session-effects instance with its context.
105 > */
106 > class LocalChatCommandRegistryImpl {
107 > private readonly _ctors: ILocalChatCommandCtor[] = [];
108 >
109 > register(ctor: ILocalChatCommandCtor): void {
110 > this._ctors.push(ctor);
111 > }
112 >
113 > createAll(context: ILocalChatCommandContext): ILocalChatCommand[] {
114 > return this._ctors.map(ctor => new ctor(context)); agentSideEffects.ts ×6
115 > }
117 >
118 > export const LocalChatCommandRegistry = new LocalChatCommandRegistryImpl();
119 >
120 > /**
121 > * Dispatches just-started turns to the registered {@link ILocalChatCommand}s
122 > * and owns everything a host-handled command needs end-to-end: it builds the
123 > * {@link ILocalChatCommandContext} from the state manager and injected services,
124 > * runs the first accepting command, then performs the common tail — completing
125 > * the turn, persisting it as a local turn (so it survives reload and anchors
126 > * fork/truncate), and asking the owner to drain the message queue.
127 > */
128 > export class AgentHostLocalCommands extends Disposable {
129 >
130 > private readonly _commands: readonly ILocalChatCommand[];
131 >
132 > constructor(
133 > private readonly _stateManager: AgentHostStateManager, agentSideEffects.ts ×6
134 > private readonly _localTurns: AgentHostLocalTurns,
135 > /**
136 > * Invoked after a handled turn is completed so the owner can start the
137 > * next queued message. Draining re-enters the agent-send pipeline, which
138 > * is the owner's concern — not the dispatcher's.
139 > */
140 > private readonly _notifyTurnConsumable: (turnChannel: ProtocolURI) => void,
141 > @ILogService private readonly _logService: ILogService,
142 > @IAgentHostTerminalManager private readonly _terminalManager: IAgentHostTerminalManager,
143 > @ISessionDataService private readonly _sessionDataService: ISessionDataService,
144 > ) {
145 > super();
146 > const context: ILocalChatCommandContext = {
147 > logService: this._logService,
148 > terminalManager: this._terminalManager,
149 > dispatch: (channel, action) => this._stateManager.dispatchServerAction(channel, action),
150 > getState: channel => this._stateManager.getSessionState(channel),
151 > updateChatTitle: (session, chat, title) => this._stateManager.updateChatTitle(session, chat, title),
152 > persistSessionFlag: (session, key, value) => persistSessionMetadata(this._sessionDataService, this._logService, session, key, value),
153 > };
154 > this._commands = LocalChatCommandRegistry.createAll(context).map(command => this._register(command));
155 > }
157 > /**
158 > * Offers `request` to each command. When one handles it, the dispatcher has
159 > * already scheduled its `run`; it returns the {@link ILocalChatCommandHandling}
160 > * so the caller can act on carried metadata such as
161 > * {@link ILocalChatCommandHandling.suggestedTitle}. Its presence means the
162 > * caller MUST NOT forward the message to the agent (and MUST NOT invoke `run`
163 > * again). Returns `undefined` when no command applies.
164 > */
165 > tryHandle(request: ILocalChatCommandRequest): ILocalChatCommandHandling | undefined {
166 > for (const command of this._commands) { localChatCommand.ts ×3
167 > const handling = command.tryHandle(request);
168 > if (handling) {
169 > void this._run(command, handling, request); localChatCommand.ts ×9
170 > return handling;
171 > }
173 > return undefined; bangLocalCommand.ts ×1
176 > private async _run(command: ILocalChatCommand, handling: ILocalChatCommandHandling, request: ILocalChatCommandRequest): Promise<void> {
177 > const stopWatch = StopWatch.create(false); localChatCommand.ts ×9
178 > try {
179 > await handling.run();
180 > } catch (err) {
181 this._logService.error(`[AgentHostLocalCommands] Command '${command.name}' failed: ${err instanceof Error ? err.message : String(err)}`, err);
182 > } finally { localChatCommand.ts ×9
183 > // Common tail for every host-handled command: close out the turn the
184 > // reducer opened, optionally persist it as a local turn (so it
185 > // survives reload and anchors fork/truncate), then let the owner
186 > // drain any messages queued behind it.
187 > this._stateManager.dispatchServerAction(request.turnChannel, { type: ActionType.ChatTurnComplete, turnId: request.turnId, duration: Math.max(0, stopWatch.elapsed()) });
188 > if (command.recordsLocalTurn) {
189 > this._recordLocalTurn(request.turnChannel, request.turnId);
190 > }
191 > this._notifyTurnConsumable(request.turnChannel);
192 > }
193 > }
195 > /**
196 > * Records the just-completed turn `turnId` as a host-injected local turn so
197 > * it survives reload and fork/truncate can resolve it to the preceding
198 > * concrete turn. Works uniformly for the default chat and any peer chat —
199 > * the turn is keyed by its chat channel. Live terminal references are
200 > * stripped from the payload (the PTY does not survive a reload).
201 > */
202 > private _recordLocalTurn(turnChannel: ProtocolURI, turnId: string): void {
203 > const chat = turnChannel; localChatCommand.ts ×9
204 > const session = isAhpChatChannel(turnChannel) ? parseRequiredSessionUriFromChatUri(turnChannel) : turnChannel;
205 > const turns = this._stateManager.getSessionState(turnChannel)?.turns;
206 > if (!turns) {
207 return;
208 }
209 > const index = turns.findIndex(t => t.id === turnId); localChatCommand.ts ×9
210 > if (index < 0) {
211 return;
212 }
213 > // Anchor = the nearest preceding turn in this chat that is not itself a localChatCommand.ts ×9
214 > // local turn.
215 > let anchorTurnId: string | undefined;
216 > for (let i = index - 1; i >= 0; i--) {
217 > if (!this._localTurns.isLocal(chat, turns[i].id)) { localChatCommand.ts ×1
218 > anchorTurnId = turns[i].id;
219 > break;
220 > }
221 > }
222 > this._localTurns.record(session, chat, sanitizeLocalTurnForPersistence(turns[index]), anchorTurnId); localChatCommand.ts ×9
223 > }
225 >
226 > /**
227 > * Prepares a host-injected local turn for persistence by dropping live
228 > * {@link ToolResultContentType.Terminal} references from its tool calls — the
229 > * PTY does not survive a reload, so only the captured output (text) is kept.
230 > */
231 > function sanitizeLocalTurnForPersistence(turn: Turn): Turn { localChatCommand.ts ×9
232 > const responseParts = turn.responseParts.map(part => {
233 > if (part.kind !== ResponsePartKind.ToolCall) { localChatCommand.ts ×2
234 > return part; renameLocalCommand.ts ×2
235 > }
236 > const tc = part.toolCall; bangLocalCommand.ts ×10
237 > // Only these tool-call states carry `content` (a live terminal ref lives here).
238 > if (tc.status !== ToolCallStatus.Running && tc.status !== ToolCallStatus.Completed && tc.status !== ToolCallStatus.PendingResultConfirmation) { localChatCommand.ts ×2
239 return part;
240 }
241 > if (!tc.content) { bangLocalCommand.ts ×10
242 return part;
243 }
244 > const content = tc.content.filter(c => c.type !== ToolResultContentType.Terminal); bangLocalCommand.ts ×10
245 > if (content.length === tc.content.length) {
246 return part;
247 }
248 > return { ...part, toolCall: { ...tc, content } }; bangLocalCommand.ts ×10
250 > return { ...turn, responseParts };
251 > }