Skip to content

Commit c34810a

Browse files
committed
Merge roblou/agent-host-session-config (Written by Copilot)
2 parents e93dbbb + 807662d commit c34810a

50 files changed

Lines changed: 2045 additions & 164 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/vs/platform/actionWidget/browser/actionList.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export const previewSelectedActionCommand = 'previewSelectedCodeAction';
4040
export interface IActionListDelegate<T> {
4141
onHide(didCancel?: boolean): void;
4242
onSelect(action: T, preview?: boolean): void;
43+
onFilter?(filter: string, cancellationToken: CancellationToken): Promise<readonly IActionListItem<T>[]>;
4344
onHover?(action: T, cancellationToken: CancellationToken): Promise<{ canPreview: boolean } | void>;
4445
onFocus?(action: T | undefined): void;
4546
}
@@ -488,6 +489,7 @@ export class ActionListWidget<T> extends Disposable {
488489
private _hasLaidOut = false;
489490
private readonly _filterInput: HTMLInputElement | undefined;
490491
private readonly _filterContainer: HTMLElement | undefined;
492+
private readonly _filterCts = this._register(new MutableDisposable<CancellationTokenSource>());
491493

492494
private readonly _onDidRequestLayout = this._register(new Emitter<void>());
493495

@@ -622,7 +624,7 @@ export class ActionListWidget<T> extends Disposable {
622624

623625
this._register(dom.addDisposableListener(this._filterInput, 'input', () => {
624626
this._filterText = this._filterInput!.value;
625-
this._applyFilter();
627+
this._applyOrUpdateFilter();
626628
}));
627629
}
628630

@@ -659,7 +661,7 @@ export class ActionListWidget<T> extends Disposable {
659661
this._filterInput.focus();
660662
this._filterInput.value = e.key;
661663
this._filterText = e.key;
662-
this._applyFilter();
664+
this._applyOrUpdateFilter();
663665
e.preventDefault();
664666
e.stopPropagation();
665667
}
@@ -677,6 +679,25 @@ export class ActionListWidget<T> extends Disposable {
677679
this._applyFilter();
678680
}
679681

682+
private _applyOrUpdateFilter(): void {
683+
if (!this._delegate.onFilter) {
684+
this._applyFilter();
685+
return;
686+
}
687+
688+
const filterText = this._filterText;
689+
this._filterCts.value?.cancel();
690+
const cts = new CancellationTokenSource();
691+
this._filterCts.value = cts;
692+
this._delegate.onFilter(filterText, cts.token).then(items => {
693+
if (cts.token.isCancellationRequested) {
694+
return;
695+
}
696+
this._allMenuItems = [...items];
697+
this._applyFilter();
698+
}).catch(() => { /* best-effort */ });
699+
}
700+
680701
private _applyFilter(): void {
681702
const filterLower = this._filterText.toLowerCase();
682703
const isFiltering = filterLower.length > 0;
@@ -841,6 +862,8 @@ export class ActionListWidget<T> extends Disposable {
841862
hide(didCancel?: boolean): void {
842863
this._delegate.onHide(didCancel);
843864
this.cts.cancel();
865+
this._filterCts.value?.cancel();
866+
this._filterCts.clear();
844867
this._hover.clear();
845868
this._hideSubmenu();
846869
}
@@ -849,7 +872,7 @@ export class ActionListWidget<T> extends Disposable {
849872
if (this._filterInput && this._filterText) {
850873
this._filterInput.value = '';
851874
this._filterText = '';
852-
this._applyFilter();
875+
this._applyOrUpdateFilter();
853876
return true;
854877
}
855878
return false;
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/*---------------------------------------------------------------------------------------------
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 assert from 'assert';
7+
import { DeferredPromise, timeout } from '../../../../base/common/async.js';
8+
import { CancellationToken } from '../../../../base/common/cancellation.js';
9+
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
10+
import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js';
11+
import { IHoverService } from '../../../hover/browser/hover.js';
12+
import { NullHoverService } from '../../../hover/test/browser/nullHoverService.js';
13+
import { TestInstantiationService } from '../../../instantiation/test/common/instantiationServiceMock.js';
14+
import { MockKeybindingService } from '../../../keybinding/test/common/mockKeybindingService.js';
15+
import { IKeybindingService } from '../../../keybinding/common/keybinding.js';
16+
import { IOpenerService } from '../../../opener/common/opener.js';
17+
import { NullOpenerService } from '../../../opener/test/common/nullOpenerService.js';
18+
import { ActionListItemKind, ActionListWidget, IActionListItem } from '../../browser/actionList.js';
19+
20+
interface ITestActionItem {
21+
readonly id: string;
22+
}
23+
24+
function action(id: string): IActionListItem<ITestActionItem> {
25+
return { kind: ActionListItemKind.Action, label: id, item: { id } };
26+
}
27+
28+
function createActionListWidget(disposables: ReturnType<typeof ensureNoDisposablesAreLeakedInTestSuite>, options: {
29+
readonly onFilter: (filter: string, cancellationToken: CancellationToken) => Promise<readonly IActionListItem<ITestActionItem>[]>;
30+
}): ActionListWidget<ITestActionItem> {
31+
const instantiationService = disposables.add(new TestInstantiationService());
32+
instantiationService.set(IKeybindingService, new MockKeybindingService());
33+
instantiationService.set(IHoverService, NullHoverService);
34+
instantiationService.set(IOpenerService, NullOpenerService);
35+
36+
const widget = disposables.add(instantiationService.createInstance(
37+
ActionListWidget<ITestActionItem>,
38+
'testActionList',
39+
false,
40+
[action('initial')],
41+
{
42+
onHide: () => { },
43+
onSelect: () => { },
44+
onFilter: options.onFilter,
45+
},
46+
undefined,
47+
{ showFilter: true },
48+
));
49+
50+
if (widget.filterContainer) {
51+
document.body.appendChild(widget.filterContainer);
52+
disposables.add({ dispose: () => widget.filterContainer?.remove() });
53+
}
54+
document.body.appendChild(widget.domNode);
55+
disposables.add({ dispose: () => widget.domNode.remove() });
56+
widget.layout(200, 200);
57+
58+
return widget;
59+
}
60+
61+
function typeFilter(widget: ActionListWidget<ITestActionItem>, value: string): void {
62+
assert.ok(widget.filterInput);
63+
widget.filterInput.value = value;
64+
widget.filterInput.dispatchEvent(new Event('input'));
65+
}
66+
67+
suite('ActionListWidget', () => {
68+
const disposables = ensureNoDisposablesAreLeakedInTestSuite();
69+
70+
test('runs dynamic filter updates immediately', () => runWithFakedTimers({ useFakeTimers: true }, async () => {
71+
const filters: string[] = [];
72+
const widget = createActionListWidget(disposables, {
73+
onFilter: async filter => {
74+
filters.push(filter);
75+
return [action(`${filter}-result`)];
76+
},
77+
});
78+
79+
typeFilter(widget, 'm');
80+
typeFilter(widget, 'ma');
81+
assert.deepStrictEqual(filters, ['m', 'ma']);
82+
await timeout(0);
83+
assert.ok(widget.domNode.textContent?.includes('ma-result'));
84+
}));
85+
86+
test('ignores stale dynamic filter results', async () => {
87+
const firstResult = new DeferredPromise<readonly IActionListItem<ITestActionItem>[]>();
88+
const secondResult = new DeferredPromise<readonly IActionListItem<ITestActionItem>[]>();
89+
const filters: string[] = [];
90+
const widget = createActionListWidget(disposables, {
91+
onFilter: filter => {
92+
filters.push(filter);
93+
return filter === 'm' ? firstResult.p : secondResult.p;
94+
},
95+
});
96+
97+
typeFilter(widget, 'm');
98+
typeFilter(widget, 'ma');
99+
assert.deepStrictEqual(filters, ['m', 'ma']);
100+
101+
firstResult.complete([action('ma-stale-result')]);
102+
await timeout(0);
103+
assert.ok(!widget.domNode.textContent?.includes('ma-stale-result'));
104+
105+
secondResult.complete([action('ma-fresh-result')]);
106+
await timeout(0);
107+
assert.ok(widget.domNode.textContent?.includes('ma-fresh-result'));
108+
});
109+
});

src/vs/platform/agentHost/common/agentService.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import type { ISyncedCustomization } from './agentPluginManager.js';
1212
import { IProtectedResourceMetadata } from './state/protocol/state.js';
1313
import type { IActionEnvelope, INotification, ISessionAction, ITerminalAction } from './state/sessionActions.js';
1414
import type { IAgentSubscription } from './state/agentSubscription.js';
15-
import type { ICreateTerminalParams } from './state/protocol/commands.js';
15+
import type { ICreateTerminalParams, IResolveSessionConfigResult, ISessionConfigCompletionsResult } from './state/protocol/commands.js';
1616
import type { IResourceCopyParams, IResourceCopyResult, IResourceDeleteParams, IResourceDeleteResult, IResourceListResult, IResourceMoveParams, IResourceMoveResult, IResourceReadResult, IResourceWriteParams, IResourceWriteResult, IStateSnapshot } from './state/sessionProtocol.js';
1717
import { AttachmentType, ComponentToState, SessionStatus, StateComponents, type ICustomizationRef, type IPendingMessage, type IRootState, type ISessionInputAnswer, type ISessionInputRequest, type IToolCallResult, type PolicyState, type StringOrMarkdown, SessionInputResponseKind } from './state/sessionState.js';
1818

@@ -100,10 +100,24 @@ export interface IAgentCreateSessionConfig {
100100
readonly model?: string;
101101
readonly session?: URI;
102102
readonly workingDirectory?: URI;
103+
readonly config?: Record<string, string>;
103104
/** Fork from an existing session at a specific turn index. */
104105
readonly fork?: { readonly session: URI; readonly turnIndex: number };
105106
}
106107

108+
export const AgentHostSessionConfigBranchNameHintKey = 'branchNameHint';
109+
110+
export interface IAgentResolveSessionConfigParams {
111+
readonly provider?: AgentProvider;
112+
readonly workingDirectory?: URI;
113+
readonly config?: Record<string, string>;
114+
}
115+
116+
export interface IAgentSessionConfigCompletionsParams extends IAgentResolveSessionConfigParams {
117+
readonly property: string;
118+
readonly query?: string;
119+
}
120+
107121
/** Serializable attachment passed alongside a message to the agent host. */
108122
export interface IAgentAttachment {
109123
readonly type: AttachmentType;
@@ -336,6 +350,12 @@ export interface IAgent {
336350
/** Create a new session. Returns server-owned session metadata. */
337351
createSession(config?: IAgentCreateSessionConfig): Promise<IAgentCreateSessionResult>;
338352

353+
/** Resolve the dynamic configuration schema for creating a session. */
354+
resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise<IResolveSessionConfigResult>;
355+
356+
/** Return dynamic completions for a session configuration property. */
357+
sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise<ISessionConfigCompletionsResult>;
358+
339359
/** Send a user message into an existing session. */
340360
sendMessage(session: URI, prompt: string, attachments?: IAgentAttachment[], turnId?: string): Promise<void>;
341361

@@ -453,6 +473,12 @@ export interface IAgentService {
453473
/** Create a new session. Returns the session URI. */
454474
createSession(config?: IAgentCreateSessionConfig): Promise<URI>;
455475

476+
/** Resolve the dynamic configuration schema for creating a session. */
477+
resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise<IResolveSessionConfigResult>;
478+
479+
/** Return dynamic completions for a session configuration property. */
480+
sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise<ISessionConfigCompletionsResult>;
481+
456482
/** Dispose a session in the agent host, freeing SDK resources. */
457483
disposeSession(session: URI): Promise<void>;
458484

@@ -558,6 +584,8 @@ export interface IAgentConnection {
558584
authenticate(params: IAuthenticateParams): Promise<IAuthenticateResult>;
559585
listSessions(): Promise<IAgentSessionMetadata[]>;
560586
createSession(config?: IAgentCreateSessionConfig): Promise<URI>;
587+
resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise<IResolveSessionConfigResult>;
588+
sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise<ISessionConfigCompletionsResult>;
561589
disposeSession(session: URI): Promise<void>;
562590

563591
// ---- Terminal lifecycle -------------------------------------------------
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1f72258
1+
9be2f2b

src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
// Generated from types/actions.ts — do not edit
1010
// Run `npm run generate` to regenerate.
1111

12-
import { ActionType, type IStateAction, type IRootAgentsChangedAction, type IRootActiveSessionsChangedAction, type IRootTerminalsChangedAction, type ISessionReadyAction, type ISessionCreationFailedAction, type ISessionTurnStartedAction, type ISessionDeltaAction, type ISessionResponsePartAction, type ISessionToolCallStartAction, type ISessionToolCallDeltaAction, type ISessionToolCallReadyAction, type ISessionToolCallConfirmedAction, type ISessionToolCallCompleteAction, type ISessionToolCallResultConfirmedAction, type ISessionToolCallContentChangedAction, type ISessionTurnCompleteAction, type ISessionTurnCancelledAction, type ISessionErrorAction, type ISessionTitleChangedAction, type ISessionUsageAction, type ISessionReasoningAction, type ISessionModelChangedAction, type ISessionServerToolsChangedAction, type ISessionActiveClientChangedAction, type ISessionActiveClientToolsChangedAction, type ISessionPendingMessageSetAction, type ISessionPendingMessageRemovedAction, type ISessionQueuedMessagesReorderedAction, type ISessionInputRequestedAction, type ISessionInputAnswerChangedAction, type ISessionInputCompletedAction, type ISessionCustomizationsChangedAction, type ISessionCustomizationToggledAction, type ISessionTruncatedAction, type ISessionIsReadChangedAction, type ISessionIsDoneChangedAction, type ISessionDiffsChangedAction, type ITerminalDataAction, type ITerminalInputAction, type ITerminalResizedAction, type ITerminalClaimedAction, type ITerminalTitleChangedAction, type ITerminalCwdChangedAction, type ITerminalExitedAction, type ITerminalClearedAction } from './actions.js';
12+
import { ActionType, type IStateAction, type IRootAgentsChangedAction, type IRootActiveSessionsChangedAction, type IRootTerminalsChangedAction, type ISessionReadyAction, type ISessionCreationFailedAction, type ISessionTurnStartedAction, type ISessionDeltaAction, type ISessionResponsePartAction, type ISessionToolCallStartAction, type ISessionToolCallDeltaAction, type ISessionToolCallReadyAction, type ISessionToolCallConfirmedAction, type ISessionToolCallCompleteAction, type ISessionToolCallResultConfirmedAction, type ISessionToolCallContentChangedAction, type ISessionTurnCompleteAction, type ISessionTurnCancelledAction, type ISessionErrorAction, type ISessionTitleChangedAction, type ISessionUsageAction, type ISessionReasoningAction, type ISessionModelChangedAction, type ISessionServerToolsChangedAction, type ISessionActiveClientChangedAction, type ISessionActiveClientToolsChangedAction, type ISessionPendingMessageSetAction, type ISessionPendingMessageRemovedAction, type ISessionQueuedMessagesReorderedAction, type ISessionInputRequestedAction, type ISessionInputAnswerChangedAction, type ISessionInputCompletedAction, type ISessionCustomizationsChangedAction, type ISessionCustomizationToggledAction, type ISessionTruncatedAction, type ISessionIsReadChangedAction, type ISessionIsDoneChangedAction, type ISessionDiffsChangedAction, type ISessionConfigChangedAction, type ITerminalDataAction, type ITerminalInputAction, type ITerminalResizedAction, type ITerminalClaimedAction, type ITerminalTitleChangedAction, type ITerminalCwdChangedAction, type ITerminalExitedAction, type ITerminalClearedAction } from './actions.js';
1313

1414

1515
// ─── Root vs Session vs Terminal Action Unions ───────────────────────────────
@@ -57,6 +57,7 @@ export type ISessionAction =
5757
| ISessionIsReadChangedAction
5858
| ISessionIsDoneChangedAction
5959
| ISessionDiffsChangedAction
60+
| ISessionConfigChangedAction
6061
;
6162

6263
/** Union of session actions that clients may dispatch. */
@@ -79,6 +80,7 @@ export type IClientSessionAction =
7980
| ISessionTruncatedAction
8081
| ISessionIsReadChangedAction
8182
| ISessionIsDoneChangedAction
83+
| ISessionConfigChangedAction
8284
;
8385

8486
/** Union of session actions that only the server may produce. */
@@ -173,6 +175,7 @@ export const IS_CLIENT_DISPATCHABLE: { readonly [K in IStateAction['type']]: boo
173175
[ActionType.SessionIsReadChanged]: true,
174176
[ActionType.SessionIsDoneChanged]: true,
175177
[ActionType.SessionDiffsChanged]: false,
178+
[ActionType.SessionConfigChanged]: true,
176179
[ActionType.TerminalData]: false,
177180
[ActionType.TerminalInput]: true,
178181
[ActionType.TerminalResized]: true,

src/vs/platform/agentHost/common/state/protocol/actions.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export const enum ActionType {
5353
SessionIsReadChanged = 'session/isReadChanged',
5454
SessionIsDoneChanged = 'session/isDoneChanged',
5555
SessionDiffsChanged = 'session/diffsChanged',
56+
SessionConfigChanged = 'session/configChanged',
5657
RootTerminalsChanged = 'root/terminalsChanged',
5758
TerminalData = 'terminal/data',
5859
TerminalInput = 'terminal/input',
@@ -663,6 +664,27 @@ export interface ISessionCustomizationToggledAction {
663664
enabled: boolean;
664665
}
665666

667+
// ─── Config Actions ──────────────────────────────────────────────────────────
668+
669+
/**
670+
* Client changed a mutable config value mid-session.
671+
*
672+
* Only properties with `sessionMutable: true` in the config schema may be
673+
* changed. The server validates and broadcasts the action; the reducer merges
674+
* the new values into `state.config.values`.
675+
*
676+
* @category Session Actions
677+
* @version 1
678+
* @clientDispatchable
679+
*/
680+
export interface ISessionConfigChangedAction {
681+
type: ActionType.SessionConfigChanged;
682+
/** Session URI */
683+
session: URI;
684+
/** Updated config values (merged into existing config) */
685+
config: Record<string, string>;
686+
}
687+
666688
// ─── Truncation ──────────────────────────────────────────────────────────────
667689

668690
/**
@@ -1006,6 +1028,7 @@ export type IStateAction =
10061028
| ISessionIsReadChangedAction
10071029
| ISessionIsDoneChangedAction
10081030
| ISessionDiffsChangedAction
1031+
| ISessionConfigChangedAction
10091032
| ITerminalDataAction
10101033
| ITerminalInputAction
10111034
| ITerminalResizedAction

0 commit comments

Comments
 (0)