Repository navigation
Expand file tree
/
Copy pathagentService.ts
More file actions
6867 lines (6468 loc) · 317 KB
/
Copy pathagentService.ts
File metadata and controls
6867 lines (6468 loc) · 317 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { open, unlink, type FileHandle } from 'fs/promises';
import { decodeBase64, encodeBase64, VSBuffer } from '../../../base/common/buffer.js';
import { Barrier, DeferredPromise, disposableTimeout, Limiter, ResourceQueue } from '../../../base/common/async.js';
import { toErrorMessage } from '../../../base/common/errorMessage.js';
import { Emitter } from '../../../base/common/event.js';
import { Disposable, DisposableMap, DisposableResourceMap, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../base/common/lifecycle.js';
import { getExtensionForMimeType, getMediaMime, getMediaOrTextMime } from '../../../base/common/mime.js';
import { Schemas } from '../../../base/common/network.js';
import { dirname as resourcesDirname, extname as resourcesExtname, extUriBiasedIgnorePathCase, isEqual, isEqualOrParent, joinPath } from '../../../base/common/resources.js';
import { URI } from '../../../base/common/uri.js';
import { generateUuid } from '../../../base/common/uuid.js';
import { hasKey } from '../../../base/common/types.js';
import { localize } from '../../../nls.js';
import { FileChangeType, FileOperationResult, IFileChange, IFileService, toFileOperationResult, type FileChangesEvent } from '../../files/common/files.js';
import { IInstantiationService } from '../../instantiation/common/instantiation.js';
import { ILogService } from '../../log/common/log.js';
import { AgentProvider, AgentSession, AgentSignal, IAgent, type IAgentAdoptedWorktree, IAgentChatContext, IAgentChatDataChange, IAgentChatMetadata, IAgentCreateChatOptions, IAgentCreateChatRequestOptions, IAgentCreateChatResult, IAgentCreateChatSideChatSelection, IAgentCreateChatSideChatSource, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDiscoveredChat, IAgentMaterializeChatEvent, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentChatAdoptionResult, type AgentChatAdoptionReason, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSpawnChatEvent, AuthenticateParams, AuthenticateResult, SubagentChatSignal, subagentChatTitle } from '../common/agent.js';
import { type AgentHostDebugLogsArtifactKind, type IAgentHostDebugLogsArtifact, type IAgentHostDebugLogsChunk, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult, IAgentService } from '../common/agentService.js';
import { ISessionDataService, SESSION_ATTACHMENTS_DIRNAME } from '../common/sessionDataService.js';
import { IAgentEditAttributionService, ICancelEditAttributionFlushParams, ICommitEditAttributionFlushParams, IEditAttributionFlushResult, IPrepareEditAttributionFlushParams, IPreparedEditAttributionFlush, parseEditAttributionResource } from '../common/fileEditAttribution.js';
import { SessionConfigKey } from '../common/sessionConfigKeys.js';
import type { IAgentCustomizationSettingsRegistration } from '../common/agentCustomizationSettings.js';
import { buildAnnotationsUri, parseAnnotationsUri } from '../common/annotationsUri.js';
import { parseChangesetUri } from '../common/changesetUri.js';
import { ActionType, ActionEnvelope, AuthRequiredReason, INotification, isAnnotationsAction, isPassiveSessionMetadataAction, isSessionAction, type ChatAction, type IIsArchivedChangedAction, type IIsReadChangedAction, type IRootConfigChangedAction, type SessionAction, type SessionWorkingDirectoryAction, type TerminalAction, type ClientAnnotationsAction, type ClientChangesetAction } from '../common/state/sessionActions.js';
import { resolveSessionWorkingDirectoryAction } from '../common/state/sessionWorkingDirectories.js';
import type { CompletionsParams, CompletionsResult, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult, SessionConfigPropertySchema } from '../common/state/protocol/commands.js';
import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js';
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 Annotation, type AnnotationEntry, type AnnotationOrigin, type AnnotationsState, type ChatOrigin, type Customization, type Message, type MessageAttachment, type MessageResourceAttachment, type TextRange } from '../common/state/protocol/state.js';
import type { ChatPendingMessageSetAction, ChatTurnStartedAction, SessionConfigChangedAction } from '../common/state/protocol/actions.js';
import { ISessionGitHubState, ISessionGitState, MessageKind, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, AH_META_CREATED_BY_SESSION_DB_KEY, readSessionCreationReference, readSessionSpawnDepth, withSessionSpawnDepth, withSessionCreationReference, parseSessionCreationReference, SessionLifecycle, SessionStatus, ToolCallStatus, ToolResultContentType, TurnState, AH_META_WORKSPACELESS_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, AH_META_IS_READ_DB_KEY, buildChatUri, buildDefaultChatUri, buildResourceWatchChannelUri, buildSubagentChatUri, buildSubagentSessionUriPrefix, getErrorResponsePart, isAhpChatChannel, isChatReadOnly, isDefaultChatUri, isSubagentChatUri, isSubagentSession, needsSessionGitStateRefresh, parseChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSessionMultiRootMetadata, parseSubagentSessionUri, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, withMessageHiddenFromTranscript, withSessionExternal, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionStatusFlag, withSessionWorkspaceless, withSessionEhcliAdopted, withSessionFolderPickerDecision, readSessionFolderPickerDecision, parseSessionFolderPickerDecision, SESSION_META_FOLDER_PICKER_KEY, readSessionEhcliAdoptable, type ISessionSourceControlState, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js';
import { readToolCallMeta } from '../common/meta/agentToolCallMeta.js';
import { isHostSnapshotAttachment, toHostSnapshotAttachmentMeta } from '../common/meta/agentSnapshotAttachmentMeta.js';
import { readEphemeralSessionMeta, withEphemeralSessionMeta } from '../common/meta/agentEphemeralSessionMeta.js';
import { IAgentMessageDelegationMeta, toAgentMessageDelegationMeta } from '../common/meta/agentMessageDelegationMeta.js';
import { readChatSurfaceMeta, withChatSurfaceMeta } from '../common/meta/agentChatSurfaceMeta.js';
import { AgentConfigurationService, getEffectiveWorkingDirectories } from './agentConfigurationService.js';
import { IAgentHostTerminalManager } from './agentHostTerminalManager.js';
import { ISessionDbUriFields, parseSessionDbUri } from '../common/sessionDbUri.js';
import { IGitBlobUriFields, parseGitBlobUri } from './gitDiffContent.js';
import { resolveSessionRepositories } from './agentHostSessionRepositories.js';
import { findDeepestContainingWorkingDirectory, isMultiRootSession } from '../common/agentHostWorkingDirectories.js';
import { AgentHostStateManager, IAgentHostStateManager } from './agentHostStateManager.js';
import { createAgentChatContext } from './agentChatContext.js';
import { AgentHostDebugLogsCollector, type IAgentHostDebugLogsEnvironment } from './agentHostDebugLogs.js';
import { IAgentHostDatabase } from './agentHostDatabase.js';
import { AgentSessionRegistry, IRegisteredSession, IStoredRegisteredSession } from './agentSessionRegistry.js';
import { IAgentHostGitService } from '../common/agentHostGitService.js';
import { IAgentHostSubscriptionService, resolveAgentHostSession } from '../common/agentHostSubscriptionService.js';
import { AgentSideEffects, type IAgentSideEffectsOptions } from './agentSideEffects.js';
import { AgentHostLocalTurns } from './agentHostLocalTurns.js';
import { AgentSessionResidency } from './agentSessionResidency.js';
import { IAgentHostSessionOpenTelemetry, type IAgentHostSessionOpenTelemetryScope } from './agentHostSessionOpenTelemetry.js';
import { AgentServerToolHost } from './shared/agentServerToolHost.js';
import { type IChatContextSnapshot, type IRenameTitleResult, type ISessionCreationDefaults, type ISessionServerToolAccessor, validateRenameTitle } from './shared/sessionServerTools.js';
import { AGENT_HOST_TITLE_SOURCE_AGENT, customChatTitleMetadataKey, customChatTitleSourceMetadataKey, persistSessionMetadata, persistSessionMetadataValues, SESSION_ARTIFACTS_KEY, SESSION_CUSTOM_TITLE_KEY, SESSION_CUSTOM_TITLE_SOURCE_KEY } from './shared/persistSessionMetadata.js';
import { type IArtifactServerToolAccessor } from './shared/artifactServerTools.js';
import { parseSessionArtifacts, stringifySessionArtifacts, withSessionArtifacts, type ISessionArtifact } from '../common/sessionArtifacts.js';
import { buildWorktreeFailureNotification, IAgentHostWorktreeIsolation, WORKTREE_META_REPOSITORY_ROOT, worktreeProjectFromRepositoryRoot } from './shared/worktreeIsolation.js';
import { IAgentHostProviderService } from './agentHostProviderService.js';
import { IAgentHostCheckpointService } from '../common/agentHostCheckpointService.js';
import { IAgentHostReviewService } from '../common/agentHostReviewService.js';
import { AgentHostChangesetCoordinator } from './agentHostChangesetCoordinator.js';
import { IAgentHostCompletions } from './agentHostCompletions.js';
import { AgentHostSkillCompletionProvider } from './agentHostSkillCompletionProvider.js';
import { SessionServerToolName } from '../common/serverToolNames.js';
import { ICopilotApiService } from './shared/copilotApiService.js';
import { INetworkDiagnosticsService } from './networkDiagnosticsService.js';
import { toAgentClientUri } from '../common/agentClientUri.js';
import { AgentHostClientType } from '../common/agentHostClientInfo.js';
import { resolveLastNonLocalTurnId } from '../common/agentHostConversationContext.js';
import { AgentHostLaunchKind, createUnknownAgentHostClientTelemetryContext, type IAgentHostClientTelemetryContext } from '../common/agentHostTelemetry.js';
import { IAgentHostGitHubEndpointService } from './agentHostGitHubEndpointService.js';
import { AgentMergeController, type IAgentMergeControllerOptions } from './agentMergeController.js';
import { AgentMergeConfigKey, agentMergeRootConfigSchema, getNonMergeSessionConfigValues, readAgentMergeSessionState } from '../common/agentMerge.js';
import { AgentSystemNotificationKind, toAgentSystemNotificationMeta } from '../common/meta/agentSystemNotificationMeta.js';
import { ITelemetryService } from '../../telemetry/common/telemetry.js';
import { AgentHostAuthenticationService } from './agentHostAuthenticationService.js';
import { updateAgentHostTelemetryLevelFromConfig } from './agentHostTelemetryService.js';
import { AgentHostActiveAgentTitleGenerationConfigKey, AgentHostArtifactToolsConfigKey, AgentHostEditTelemetryEnabledConfigKey, AgentHostExternalSessionsMode, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, AgentHostShowExternalSessionsConfigKey, platformRootSchema } from '../common/agentHostSchema.js';
import { IAgentHostChangesetService, CHANGESET_DB_METADATA_KEYS, META_CHANGES_SUMMARY } from '../common/agentHostChangesetService.js';
import { GIT_DB_METADATA_KEYS, IAgentHostGitStateService, META_GIT_STATE, META_GITHUB_STATE, META_SOURCE_CONTROL_STATE } from '../common/agentHostGitStateService.js';
import { IAgentHostChangesetOperationService } from '../common/agentHostChangesetOperationService.js';
import { IAgentHostChatContributions } from '../common/agentHostChatContributionsService.js';
/**
* Grace period before an empty, unsubscribed session is garbage-collected
* via {@link AgentService._runSessionGc}. Gives a disconnected client time
* to reconnect (or a workspace switch to settle) before we tear down the
* provider-side session, worktree, and on-disk state.
*/
const SESSION_GC_GRACE_MS = 30_000;
const DAY_MS = 24 * 60 * 60 * 1000;
const EXTERNAL_SESSION_MAX_AGE_MS = 30 * DAY_MS;
const RECENT_EXTERNAL_SESSION_LIMIT = 2;
/**
* How many locally created sessions must postdate an external session's last
* update before {@link AgentHostExternalSessionsMode.Recent} stops surfacing it.
*/
const RECENT_EXTERNAL_SUPERSEDING_LOCAL_LIMIT = 2;
/** A catalog pass slower than this is logged at info, since it delays every session-list refresh. */
const SLOW_LIST_SESSIONS_THRESHOLD_MS = 1_000;
type AgentHostLegacyMigrationEvent = {
provider: string;
outcome: 'migrated' | 'skipped' | 'failed';
success: boolean;
turnCount: number;
durationMs: number;
hasProject: boolean;
hasWorktree: boolean;
workingDirectoryCount: number;
errorMessage: string | undefined;
reason: string;
};
type AgentHostLegacyMigrationClassification = {
provider: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The agent provider id whose legacy session was migrated (e.g. copilotcli).' };
outcome: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Migration outcome: migrated (adoption + restore completed), skipped (eligible legacy session not adopted this pass, e.g. migrate flag not yet applied), or failed (adoption or restore threw).' };
success: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the migration completed with at least one restored turn.' };
turnCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of turns restored from the migrated session.' };
durationMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Time in milliseconds to adopt and restore the legacy session.' };
hasProject: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the migrated session resolved to a project/repository.' };
hasWorktree: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the migrated session ran in a pre-existing git worktree that was bridged during adoption.' };
workingDirectoryCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of working directories associated with the migrated session.' };
errorMessage: { classification: 'CallstackOrException'; purpose: 'PerformanceAndHealth'; comment: 'Error message when the migration failed; absent for migrated/skipped outcomes.' };
reason: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Why adoption ended as it did: adopted, alreadyNative, notLegacyChat, workingDirectoryMissing, or unknown. Separates a skipped session that was never ours from one whose working directory vanished, which need different fixes.' };
owner: 'vijayupadya';
comment: 'Tracks one-time adopt-on-open migration of legacy extension-host Copilot CLI sessions into the agent host to measure attempt, success, failure, and skipped rates.';
};
const HOST_OWNED_SESSION_CONFIG_KEYS = [
SessionConfigKey.AgentMerge,
SessionConfigKey.AgentMergeController,
SessionConfigKey.Isolation,
SessionConfigKey.Branch,
SessionConfigKey.WorktreeBranchPrefix,
SessionConfigKey.WorktreeIncludeFiles,
SessionConfigKey.WorktreeBranchTrack,
SessionConfigKey.WorktreeCreateNewBranch,
] as const;
/**
* Host-owned session config a client may never write. These carry Agent Merge
* authorization state (bound pull request, feedback watermark, attempt budgets)
* that the host derives itself.
*/
const HOST_WRITTEN_SESSION_CONFIG_KEYS = [
SessionConfigKey.AgentMergeController,
] as const;
function omitHostOwnedSessionConfig<T>(config: Record<string, T>): Record<string, T> {
const result = { ...config };
for (const key of HOST_OWNED_SESSION_CONFIG_KEYS) {
delete result[key];
}
return result;
}
function parsePersistedSourceControlState(value: string): ISessionSourceControlState {
const state = readSessionSourceControlState({
[SESSION_META_SOURCE_CONTROL_KEY]: JSON.parse(value),
});
if (!state) {
throw new Error('Invalid persisted source-control state');
}
return state;
}
/**
* Grace period before an idle resource watch is torn down after its last
* subscriber unsubscribes (mirrors {@link SESSION_GC_GRACE_MS}). Within
* this window, a re-subscribe (or reconnect) reuses the still-running
* {@link IFileService} watcher so transient drop-outs don't miss change
* events. Resource watch action envelopes flow through the normal
* envelope replay buffer for the same reason.
*/
const RESOURCE_WATCH_GRACE_MS = 30_000;
/** Bound on how long {@link AgentService.subscribe} waits for a pending subagent chat to register before giving up. */
const SUBAGENT_CHAT_PENDING_TIMEOUT_MS = 15_000;
/**
* Session-database metadata key for the orchestrator-owned catalog of
* additional peer chats. When absent, the session predates this persistence
* and a one-time migration drains the agent's legacy `*.chats` state.
*/
const PEER_CHATS_METADATA_KEY = 'peerChats';
const ANNOTATIONS_METADATA_KEY = 'annotations';
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function isPersistedAnnotationEntry(value: unknown): value is AnnotationEntry {
if (!isRecord(value) || typeof value.id !== 'string') {
return false;
}
return typeof value.text === 'string'
|| (isRecord(value.text) && typeof value.text.markdown === 'string');
}
function isPersistedAnnotationOrigin(value: unknown): value is AnnotationOrigin {
return isRecord(value)
&& typeof value.session === 'string'
&& (value.chat === undefined || typeof value.chat === 'string')
&& (value.turnId === undefined || typeof value.turnId === 'string');
}
function isPersistedTextRange(value: unknown): value is TextRange {
return isRecord(value)
&& isRecord(value.start) && typeof value.start.line === 'number' && typeof value.start.character === 'number'
&& isRecord(value.end) && typeof value.end.line === 'number' && typeof value.end.character === 'number';
}
/**
* Reads one persisted annotation, migrating the pre-`origin` shape. Releases
* before the annotation origin recorded a top-level `turnId` and no owning
* session, so the session that is being restored supplies the origin.
*/
function readPersistedAnnotation(value: unknown, session: string): Annotation | undefined {
if (!isRecord(value)
|| typeof value.id !== 'string'
|| typeof value.resource !== 'string'
|| typeof value.resolved !== 'boolean'
|| !Array.isArray(value.entries)
|| value.entries.length === 0
|| !value.entries.every(isPersistedAnnotationEntry)) {
return undefined;
}
let origin: AnnotationOrigin;
if (isPersistedAnnotationOrigin(value.origin)) {
origin = value.origin;
} else if (value.origin === undefined) {
origin = { session, ...(typeof value.turnId === 'string' && value.turnId ? { turnId: value.turnId } : {}) };
} else {
return undefined;
}
const annotation: Annotation = {
id: value.id,
origin,
resource: value.resource,
resolved: value.resolved,
entries: value.entries,
};
if (isPersistedTextRange(value.range)) {
annotation.range = value.range;
}
if (isRecord(value._meta)) {
annotation._meta = value._meta;
}
return annotation;
}
/**
* Reads a persisted annotations state, migrating any legacy annotation into the
* current shape. Returns `undefined` when the payload is not a valid state.
*/
function readPersistedAnnotationsState(value: unknown, session: string): AnnotationsState | undefined {
if (!isRecord(value) || !Array.isArray(value.annotations)) {
return undefined;
}
const annotations: Annotation[] = [];
for (const entry of value.annotations) {
const annotation = readPersistedAnnotation(entry, session);
if (!annotation) {
return undefined;
}
annotations.push(annotation);
}
return { annotations };
}
/** Opaque provider data for the session's default chat. */
const DEFAULT_CHAT_PROVIDER_DATA_METADATA_KEY = 'defaultChatProviderData';
/**
* Session-database metadata key written on a chat's backing SDK session.
* Marks that session as an internal chat backing so legacy enumeration never
* surfaces it as a top-level session; the value is the owning chat URI.
*/
const CHAT_BACKING_METADATA_KEY = 'peerChatBacking';
/**
* A single entry in the orchestrator's persisted peer-chat catalog. `uri` is
* the peer chat's channel URI; `providerData` is the opaque, agent-owned blob
* (see {@link IAgentCreateChatResult.providerData}) handed back to the agent on
* restore — the orchestrator never parses it. `providerData` may be omitted,
* in which case the agent recovers its backing from its own persistence on
* {@link IAgent.materializeChat}. `origin` records the chat's provenance
* (currently only {@link ChatOriginKind.SideChat}, carrying the source chat and
* stable source turn id) so it survives a restart; omitted for plain peer chats.
*/
interface IPersistedPeerChat {
readonly uri: string;
readonly providerData?: string;
readonly origin?: ChatOrigin;
readonly inheritedTurnId?: string;
}
/**
* Tracks one provider's in-flight external-chat discovery attempt. `promise` is
* reassigned in place when a `force` request is chained onto an attempt that
* is already running, so
* callers that captured an earlier reference to the same `IProviderDiscoveryState`
* still observe the chained, forced re-run.
*/
interface IProviderDiscoveryState {
promise: Promise<void>;
forceQueued: boolean;
}
class ProviderCatalogUnavailableError extends Error {
constructor(readonly provider: AgentProvider) {
super(`Provider ${provider} cannot enumerate its native session catalog yet`);
this.name = 'ProviderCatalogUnavailableError';
}
}
/**
* Reconcile a session's working-directory set from a create-result /
* materialization receipt. The resolved receipt is authoritative for the roots
* it reports (index 0 = the resolved process root, e.g. a worktree); any
* additional requested/current roots *beyond* the resolved set's length are
* preserved. This is what lets a receipt that reports only the process root —
* the resume path reads a single cwd from disk — keep the rest of the known set
* instead of collapsing `[A, B, C]` to `[dir]`, while a receipt that carries the
* full resolved set (the send/create path) is trusted verbatim (including a
* remapped tail). A missing resolved set keeps the requested value as-is,
* preserving the `undefined` (workspace-less / inherit) vs `[]` (explicitly none)
* distinction.
*
* Returns the protocol form (`string[]`), since protocol URIs are strings.
*/
function reconcileWorkingDirectories(requested: readonly URI[] | undefined, resolved: readonly URI[] | undefined): string[] | undefined {
if (resolved === undefined) {
return requested?.map(d => d.toString());
}
const tail = (requested ?? []).slice(resolved.length);
return [...resolved, ...tail].map(d => d.toString());
}
export interface IAgentServiceOptions {
readonly rootConfigResource?: URI;
readonly copilotApiService?: ICopilotApiService;
readonly providerConfigurations?: readonly IAgentCustomizationSettingsRegistration[];
readonly hostLaunchKind?: AgentHostLaunchKind;
readonly storageResource?: URI;
readonly orchestratorDatabase?: IAgentHostDatabase;
readonly debugLogsEnvironment?: IAgentHostDebugLogsEnvironment;
readonly sessionResidencyLimit?: number;
readonly sessionReleaseRetryMs?: number;
}
export interface IAgentServiceCallbacks {
readonly canEvictChangeset: (changeset: string) => boolean;
readonly startAgentMergeTurn: IAgentMergeControllerOptions['startTurn'];
readonly cancelAgentMergeTurn: IAgentMergeControllerOptions['cancelTurn'];
readonly postAgentMergeNotice: IAgentMergeControllerOptions['postNotice'];
readonly getAutonomousSessionConfig: IAgentMergeControllerOptions['getAutonomousSessionConfig'];
readonly resolveWorkingDirectoryBeforeSend: NonNullable<IAgentSideEffectsOptions['resolveWorkingDirectoryBeforeSend']>;
readonly resolveChatAttachmentTurns: NonNullable<IAgentSideEffectsOptions['resolveChatAttachmentTurns']>;
readonly getSessionMetadata: (session: URI) => Promise<IAgentSessionMetadata | undefined>;
readonly restoreSession: (session: URI) => Promise<void>;
readonly sessionServerToolAccessor: ISessionServerToolAccessor;
readonly artifactServerToolAccessor: IArtifactServerToolAccessor;
}
export interface IAgentServiceCallbackBinder {
bind(callbacks: IAgentServiceCallbacks): void;
}
export interface IAgentServiceCollaborators {
readonly gitHubEndpointService: IAgentHostGitHubEndpointService;
readonly gitStateService: IAgentHostGitStateService;
readonly agentMergeController: AgentMergeController;
readonly checkpointService: IAgentHostCheckpointService;
readonly changesetOperationService: IAgentHostChangesetOperationService;
readonly reviewService: IAgentHostReviewService;
readonly changesets: IAgentHostChangesetService;
readonly changesetCoordinator: AgentHostChangesetCoordinator;
readonly completions: IAgentHostCompletions;
readonly terminalManager: IAgentHostTerminalManager;
readonly localTurns: AgentHostLocalTurns;
readonly sideEffects: AgentSideEffects;
readonly serverToolHost: AgentServerToolHost;
}
/** Core services that must exist before {@link AgentService} can be constructed. */
export interface IAgentServiceCore {
readonly disposables: DisposableStore;
readonly authenticationService: AgentHostAuthenticationService;
readonly orchestratorDatabase: IAgentHostDatabase;
readonly debugLogsCollector: AgentHostDebugLogsCollector | undefined;
readonly sessionRegistry: AgentSessionRegistry;
readonly stateManager: AgentHostStateManager;
readonly configurationService: AgentConfigurationService;
readonly callbackBinder: IAgentServiceCallbackBinder;
}
/**
* The agent service implementation that runs inside the agent-host utility
* process. Dispatches to registered {@link IAgent} instances based
* on the provider identifier in the session configuration.
*/
export class AgentService extends Disposable implements IAgentService {
declare readonly _serviceBrand: undefined;
private readonly _resourceWriteQueue = this._register(new ResourceQueue());
/** Protocol: fires when state is mutated by an action. */
private readonly _onDidAction = this._register(new Emitter<ActionEnvelope>());
readonly onDidAction = this._onDidAction.event;
/** Protocol: fires for ephemeral notifications (sessionAdded/Removed). */
private readonly _onDidNotification = this._register(new Emitter<INotification>());
readonly onDidNotification = this._onDidNotification.event;
/** Protocol: fires for MCP server-originated notifications routed over `mcp://` channels. */
readonly onMcpNotification: IAgentService['onMcpNotification'];
/** Authoritative state manager for the sessions process protocol. */
private readonly _stateManager: AgentHostStateManager;
/**
* Orchestrator-owned durable index of known sessions. Populated alongside
* create/delete paths and, in Stage 1, exposed only for parity validation.
*/
private readonly _sessionRegistry: AgentSessionRegistry;
private readonly _orchestratorDatabase: IAgentHostDatabase;
/** Serializes durable last-modified advances emitted by live session state. */
private _sessionModifiedTimeWrites: Promise<void> = Promise.resolve();
private readonly _providerMigrations = new Map<AgentProvider, IProviderDiscoveryState>();
private readonly _initialProviderMigrations = new Map<AgentProvider, Promise<void>>();
/**
* Backing-session URIs (as strings) whose {@link CHAT_BACKING_METADATA_KEY}
* durable marker write kept failing after a retry in `createChat`. The chat
* itself was already created and announced successfully, so this in-process
* suppression stands in for the durable marker: it is consulted by
* {@link _isChatBacking} (used by external discovery) and by `listSessions`'s overlay
* filter, so the backing session is still never surfaced as a standalone
* top-level session for the lifetime of this process, even though its
* on-disk marker never persisted. A later successful write (e.g. from a
* differently-timed retry) removes the entry; a stale entry for a since
* deleted session is harmless — that URI is never reachable again.
*/
private readonly _unpersistedChatBackings = new Set<string>();
/**
* Sessions that have opted in to bring-up progress, keyed by provider id.
* A session is added here when its `createSession` carries a
* {@link IAgentCreateSessionConfig.progressToken} and removed once it
* materializes (the SDK is now resolved) or is disposed. The SDK download is
* host-level and shared across every session of a provider, so this only
* records *interest*: as long as one or more sessions of a provider is
* registered, {@link emitDownloadProgress} surfaces that provider's download as a single
* progress stream keyed by the download's own identity (the package id),
* rather than one stream per session.
*/
private readonly _downloadProgressInterest = new Map<AgentProvider, Set<string>>();
/** AgentService-owned integrations installed for registered providers. */
private readonly _providerSubscriptions = this._register(new DisposableMap<AgentProvider, DisposableStore>());
/**
* Per-session tail of in-flight persisted peer-chat catalog writes, keyed by
* session URI string. Read-modify-write updates to the {@link
* PEER_CHATS_METADATA_KEY} blob are chained per session so a `createChat`,
* `disposeChat`, and `onDidChangeChatData` racing for the same
* session can't clobber each other's edits.
*/
private readonly _peerChatCatalogWrites = new Map<string, Promise<void>>();
private readonly _disposingPeerChats = new Set<string>();
private readonly _defaultChatBackingWrites = new Map<string, Promise<void>>();
private readonly _authService: AgentHostAuthenticationService;
/** Shared side-effect handler for action dispatch and session lifecycle. */
private readonly _sideEffects: AgentSideEffects;
private readonly _agentMergeController: AgentMergeController;
/** Owns static / per-turn changeset compute, publish, persist, restore. */
private readonly _changesets: IAgentHostChangesetService;
/** Shared active changeset subscription registry. */
/** Owns changeset operation contributions and handler activation. */
private readonly _changesetOperationService: IAgentHostChangesetOperationService;
private readonly _reviewService: IAgentHostReviewService;
/** Owns AgentService-side orchestration of the changeset feature. */
private readonly _changesetCoordinator: AgentHostChangesetCoordinator;
/** Owns session git-state probing and git-backed catalogue decoration. */
private readonly _gitStateService: IAgentHostGitStateService;
/** Manages PTY-backed terminals for the agent host protocol. */
private readonly _terminalManager: IAgentHostTerminalManager;
/** Persists host-injected `/rename` / `!command` turns for restore & fork/truncate. */
private readonly _localTurns: AgentHostLocalTurns;
/** Server-side host for the agent host's server tools. */
private readonly _serverToolHost: AgentServerToolHost;
private readonly _debugLogsCollector: AgentHostDebugLogsCollector | undefined;
private readonly _configurationService: AgentConfigurationService;
/** Captures baseline / per-turn git checkpoints backing the changeset pipeline. */
private readonly _checkpointService: IAgentHostCheckpointService;
/** Single source of truth for GitHub (Enterprise) endpoints and protected resources. */
private readonly _gitHubEndpointService: IAgentHostGitHubEndpointService;
/** Pluggable completion item providers (e.g. workspace file completions, agent-specific @-mentions). */
private readonly _completions: IAgentHostCompletions;
private _skillCompletionProviderRegistered = false;
/**
* Authoritative server-side per-resource subscription refcount, keyed by
* resource URI string and valued by the set of subscribed protocol
* client IDs. Populated by {@link addSubscriber} after any in-flight release
* settles (or immediately for handshake fast-paths) and drained by
* {@link unsubscribe}. When a
* resource's set becomes empty, the resource is dropped from the map and
* session residency is reconciled against the MRU cap.
*/
private readonly _restoreSessionInFlight = new Map<string, Promise<void>>();
private readonly _restoreSubagentInFlight = new Map<string, Promise<void>>();
private readonly _sessionResidency: AgentSessionResidency;
/**
* Persisted-annotation reads in flight, keyed by session URI. Annotations
* snapshots are synthesized empty for any well-formed URI, so subscribers
* must await this rather than rely on session-state existence.
*/
private readonly _restoreAnnotationsInFlight = new Map<string, Promise<void>>();
/** Subagent chats armed for a bounded wait (once execution is confirmed); resolved by {@link _onChatSpawned}, awaited by {@link subscribe}. */
private readonly _pendingSubagentChats = new Map<string /* subagentChatUri */, DeferredPromise<void>>();
private readonly _pendingSubagentChatTimeouts = this._register(new DisposableMap<string /* subagentChatUri */, IDisposable>());
/** Subagent chats announced via `_meta.subagentChatUri` but still awaiting confirmation, keyed by `${channel}:${toolCallId}`. */
private readonly _pendingSubagentToolCalls = new Map<string, string /* subagentChatUri */>();
/**
* Pending {@link _runSessionGc} timers, keyed by session URI. A timer is
* armed when a session loses its last subscriber while still empty (no
* turns, no active turn) — see {@link _maybeScheduleSessionGc}. Cleared
* whenever any client subscribes again or the timer fires.
*/
private readonly _pendingSessionGc = this._register(new DisposableResourceMap<IDisposable>());
/**
* Active resource watches keyed by the channel URI string
* (`ahp-resource-watch:/<encoded>`).
*
* Each entry owns the {@link IFileService} watcher together with the
* decoded descriptor, the subscriber refcount, and the optional
* grace-window dispose timer. The watch URI itself is fully
* self-describing — {@link createResourceWatch} just encodes the
* caller's params into the URI and returns it. State only exists
* here once at least one client has subscribed.
*
* Lifecycle:
* - First subscriber to a channel: {@link onResourceWatchSubscribed}
* parses the URI, creates the {@link IFileService} watcher, and
* installs the entry with `subscribers = 1`.
* - Subsequent subscribers bump the refcount and cancel any pending
* grace-window dispose timer.
* - {@link onResourceWatchUnsubscribed} drops the refcount; when it
* reaches zero we arm a {@link RESOURCE_WATCH_GRACE_MS} dispose
* timer rather than tearing down immediately, giving disconnected
* clients time to reconnect.
*/
private readonly _resourceWatches = this._register(new DisposableMap<string, IActiveResourceWatch>());
constructor(
core: IAgentServiceCore,
collaborators: IAgentServiceCollaborators,
options: IAgentServiceOptions,
@ILogService private readonly _logService: ILogService,
@IFileService private readonly _fileService: IFileService,
@ISessionDataService private readonly _sessionDataService: ISessionDataService,
@IAgentHostGitService private readonly _gitService: IAgentHostGitService,
@ITelemetryService private readonly _telemetryService: ITelemetryService,
@IAgentHostSessionOpenTelemetry private readonly _sessionOpenTelemetry: IAgentHostSessionOpenTelemetry,
@IAgentHostChatContributions private readonly _chatContributions: IAgentHostChatContributions,
@IAgentHostSubscriptionService private readonly _subscriptions: IAgentHostSubscriptionService,
@INetworkDiagnosticsService private readonly _networkDiagnostics: INetworkDiagnosticsService,
@IAgentEditAttributionService private readonly _editAttributionService: IAgentEditAttributionService,
@IInstantiationService instantiationService: IInstantiationService,
@IAgentHostWorktreeIsolation private readonly _worktree: IAgentHostWorktreeIsolation,
@IAgentHostProviderService private readonly _providerService: IAgentHostProviderService,
) {
super();
this._authService = core.authenticationService;
this._orchestratorDatabase = core.orchestratorDatabase;
this._debugLogsCollector = core.debugLogsCollector;
this._sessionRegistry = core.sessionRegistry;
this._stateManager = core.stateManager;
this._configurationService = core.configurationService;
this.onMcpNotification = this._providerService.onMcpNotification;
this._gitHubEndpointService = collaborators.gitHubEndpointService;
this._gitStateService = collaborators.gitStateService;
this._agentMergeController = collaborators.agentMergeController;
this._checkpointService = collaborators.checkpointService;
this._changesetOperationService = collaborators.changesetOperationService;
this._reviewService = collaborators.reviewService;
this._changesets = collaborators.changesets;
this._changesetCoordinator = collaborators.changesetCoordinator;
this._completions = collaborators.completions;
this._terminalManager = collaborators.terminalManager;
this._localTurns = collaborators.localTurns;
this._sideEffects = collaborators.sideEffects;
this._serverToolHost = collaborators.serverToolHost;
this._register(this._providerService.registerProviderInitializer(provider => this._initializeProvider(provider)));
this._register(this._providerService.onDidRegisterProvider(provider => this._onDidRegisterProvider(provider)));
this._sessionResidency = this._register(instantiationService.createInstance(
AgentSessionResidency,
this._stateManager,
{
isReleaseBlocked: session => this._restoreSessionInFlight.has(session.toString()),
whenSessionDataIdle: session => this._whenSessionDataIdle(session),
getSessionChats: session => this._getSessionChatsInTeardownOrder(session),
createRelease: session => {
const provider = this._providerService.getProviderForSession(session);
return provider ? {
canRelease: chats => this._canReleaseSession(provider, session, chats),
release: chats => this._releaseSession(provider, session, chats),
} : undefined;
},
evictSessionState: (session, chats) => this._evictSessionState(session, session.toString(), session.toString(), chats.map(chat => chat.toString())),
},
{
limit: options.sessionResidencyLimit,
releaseRetryMs: options.sessionReleaseRetryMs,
holdsSession: session => this._agentMergeController.holdsSession(session),
onDidReleaseHold: this._agentMergeController.onDidReleaseHold,
},
));
core.callbackBinder.bind({
canEvictChangeset: changeset => this._canEvictChangeset(changeset),
startAgentMergeTurn: (session, turnId, prompt) => this._startAgentMergePrompt(session, turnId, prompt),
cancelAgentMergeTurn: (session, turnId) => this._cancelAgentMergePrompt(session, turnId),
postAgentMergeNotice: (session, kind, content) => this._postAgentMergeNotice(session, kind, content),
getAutonomousSessionConfig: (session, config) => this._providerService.getProviderForSession(session)?.getAutonomousSessionConfig?.(config),
resolveWorkingDirectoryBeforeSend: params => this._resolveWorkingDirectoryBeforeSend(params),
resolveChatAttachmentTurns: resource => this._resolveChatAttachmentTurns(resource),
getSessionMetadata: session => this._getSessionMetadata(session),
restoreSession: session => this.restoreSession(session),
sessionServerToolAccessor: this._createSessionServerToolAccessor(),
artifactServerToolAccessor: this._createArtifactServerToolAccessor(),
});
this._logService.info('AgentService initialized');
this._register(this._stateManager.onDidEmitEnvelope(e => this._onDidAction.fire(e)));
this._register(this._stateManager.onDidEmitEnvelope(e => this._trackPendingSubagentChatFromEnvelope(e)));
this._register(this._stateManager.onDidEmitEnvelope(e => this._persistAnnotations(e)));
// Archiving is terminal for Agent Merge, so the index is cleared from the
// action rather than from the controller's own disable.
this._register(this._stateManager.onDidEmitEnvelope(e => {
if (e.action.type === ActionType.SessionIsArchivedChanged && e.action.isArchived && !isAhpChatChannel(e.channel)) {
this._clearAgentMergeIndex(URI.parse(e.channel));
void this._sessionResidency.reconcile();
}
}));
this._register(this._stateManager.onDidEmitNotification(e => this._onDidNotification.fire(e)));
// A notice raised mid-turn waits for the agent to finish so it can own a
// turn of its own and survive restore.
this._register(this._stateManager.onDidChangeSessionActiveTurn(({ session, active }) => {
if (!active) {
this._flushAgentMergeNotices(session);
}
}));
this._register(this._stateManager.onDidRemoveSession(session => this._pendingAgentMergeNotices.delete(session)));
this._register(this._stateManager.onDidChangeSessionSummary(({ session, changes }) => {
const meta = this._stateManager.getSessionSummary(session)?._meta;
if (changes.modifiedAt !== undefined) {
this._writeSessionModifiedTime(URI.parse(session), Date.parse(changes.modifiedAt));
}
if (changes.modifiedAt !== undefined
&& this._getExternalSessionsMode() === AgentHostExternalSessionsMode.Recent
&& readSessionExternal(meta)
&& !readSessionEhcliAdoptable(meta)) {
this._queueSessionListReconciliation();
}
}));
updateAgentHostTelemetryLevelFromConfig(this._telemetryService, this._stateManager.rootState.config?.values);
this._register(this._stateManager.onDidChangeSessionConfig(({ session, previous, current }) => this._syncAgentMergeIndex(URI.parse(session), previous, current)));
let externalSessionsMode = this._getExternalSessionsMode();
this._lastMigrateLegacyEnabled = this._isMigrateLegacyEnabled();
let agentMergeEnabled = this._isAgentMergeEnabled();
this._register(this._configurationService.onDidRootConfigChange(() => {
const nextMode = this._getExternalSessionsMode();
if (nextMode !== externalSessionsMode) {
const previousMode = externalSessionsMode;
externalSessionsMode = nextMode;
// The only point past startup where `Recent` re-measures the
// superseding local sessions.
this._invalidateRecentSupersedingCutoff();
this._logService.info(`[AgentService] ${AgentHostShowExternalSessionsConfigKey} changed '${previousMode}' -> '${nextMode}'; queueing session list reconciliation`);
this._queueSessionListReconciliation(previousMode);
}
const nextAgentMergeEnabled = this._isAgentMergeEnabled();
if (nextAgentMergeEnabled !== agentMergeEnabled) {
agentMergeEnabled = nextAgentMergeEnabled;
for (const session of this._stateManager.getSessionUris()) {
this._serverToolHost.advertise(session);
}
// Turning the feature on resumes monitoring for persisted
// enabled sessions that are not in memory.
if (nextAgentMergeEnabled) {
this._agentMergeRestore = this._agentMergeRestore
.then(() => this._restoreAgentMergeMonitoredSessions())
.catch(err => this._logService.warn('[AgentService] Failed to restore Agent-Merge-enabled sessions', err));
}
}
this._onMigrateLegacySettingChanged();
}));
this._register(this._gitHubEndpointService.onDidChange(() => {
this._stateManager.emitAuthRequired({
resource: this._gitHubEndpointService.getCopilotResource(),
reason: AuthRequiredReason.Required,
});
}));
this._editAttributionService.setEnabled(this._stateManager.rootState.config?.values[AgentHostEditTelemetryEnabledConfigKey] !== false);
this._runWhenStartupSettled('external session prune', () => this._pruneStaleExternalSessions());
this._register(core.disposables);
}
/** Opens once startup settled: the host finished starting and the first listing was served. */
private readonly _startupSettled = new Barrier();
private _hostStartupComplete = false;
private _firstListingServed = false;
/** Serializes deferred work so background maintenance never overlaps. */
private _deferredWork = Promise.resolve();
/**
* Signals that host startup finished. Deferred work runs once this and the
* first session listing have both happened, so background maintenance never
* competes with startup. Called by the process mains; the service owns no
* ambient timer of its own.
*/
markStartupComplete(): void {
this._hostStartupComplete = true;
this._openStartupSettled();
}
private _openStartupSettled(): void {
if (this._hostStartupComplete && this._firstListingServed) {
this._startupSettled.open();
}
}
/**
* Runs `work` once startup has settled, serialized behind any deferred work
* queued before it. For maintenance that is fine to run late and must not
* compete with startup — pruning stale external sessions, titling external
* sessions a provider surfaced without a title, and similar.
*/
private _runWhenStartupSettled(name: string, work: () => Promise<void>): void {
this._deferredWork = this._deferredWork
.then(() => this._startupSettled.wait())
.then(() => this._store.isDisposed ? undefined : work())
.catch(error => this._logService.warn(`[AgentService] Deferred work '${name}' failed`, error));
}
/** Test surface: settles once all deferred work queued so far has run. */
async whenDeferredWorkSettled(): Promise<void> {
await this._deferredWork;
}
private async _pruneStaleExternalSessions(): Promise<void> {
const now = Date.now();
const registered = await this._listRegisteredSessions();
const staleExternalSessions: URI[] = [];
for (const entry of registered) {
if (!entry.external) {
continue;
}
const provider = this._providerService.getProvider(entry.provider);
if (!provider) {
continue;
}
let metadata: IAgentSessionMetadata | undefined;
try {
metadata = await this._registeredSessionMetadata(provider, entry.session, true);
} catch (error) {
this._logService.warn(`[AgentService] Failed to load metadata while pruning stale external session ${entry.session.toString()}`, error);
continue;
}
if (!metadata) {
continue;
}
if (readSessionEhcliAdoptable(metadata._meta)) {
continue;
}
if (this._isExternalSessionOlderThanMaxAge(metadata.modifiedTime, now)) {
staleExternalSessions.push(entry.session);
}
}
for (const session of staleExternalSessions) {
await this._sessionRegistry.unregister(session);
}
if (staleExternalSessions.length > 0) {
this._invalidateSessionList();
this._queueSessionListReconciliation();
}
this._logService.info(`[AgentService] pruned ${staleExternalSessions.length} stale external session row(s) older than ${EXTERNAL_SESSION_MAX_AGE_MS / DAY_MS} days`);
}
/** External sessions registered without a provider title, awaiting a generated one. */
private readonly _untitledExternalSessions = new Map<string, IAgentSessionMetadata>();
private _externalSessionTitlingQueued = false;
/**
* Queues external sessions whose provider surfaced them without a title.
* Titling is deferred past startup and capped at the
* {@link RECENT_EXTERNAL_SESSION_LIMIT} most recently updated candidates, so
* a large provider catalog cannot trigger a burst of model calls.
*/
private _scheduleExternalSessionTitles(sessions: readonly IAgentSessionMetadata[]): void {
for (const session of sessions) {
this._untitledExternalSessions.set(session.session.toString(), session);
}
if (this._externalSessionTitlingQueued) {
return;
}
this._externalSessionTitlingQueued = true;
this._runWhenStartupSettled('external session titles', () => {
this._externalSessionTitlingQueued = false;
return this._titleUntitledExternalSessions();
});
}
/** Titles the most recently updated queued sessions and drops the rest. */
private async _titleUntitledExternalSessions(): Promise<void> {
const candidates = [...this._untitledExternalSessions.values()]
.sort((a, b) => b.modifiedTime - a.modifiedTime)
.slice(0, RECENT_EXTERNAL_SESSION_LIMIT);
this._untitledExternalSessions.clear();
for (const candidate of candidates) {
try {
await this._generateExternalSessionTitle(candidate);
} catch (error) {
this._logService.warn(`[AgentService] Failed to title external session ${candidate.session.toString()}`, error);
}
}
}
/** Titles one external session from the first user prompt of its default chat. */
private async _generateExternalSessionTitle(metadata: IAgentSessionMetadata): Promise<void> {
const session = metadata.session;
const agent = this._providerService.getProviderForSession(session);
if (!agent) {
return;
}
const chat = URI.parse(buildDefaultChatUri(session));
const turns = await agent.chats.getMessages(chat, this._chatContext(session, chat));
const prompt = turns[0]?.message.text.trim();
if (prompt) {
await this._sideEffects.generateExternalSessionTitle(session.toString(), prompt);
}
}
// ---- provider registration ----------------------------------------------
private _toProviderConfig<T extends { readonly config?: Record<string, unknown> }>(request: T): T {
if (!this._worktree.supported || !request.config) {
return request;
}
return { ...request, config: omitHostOwnedSessionConfig(request.config) };
}
/**
* Host-owned first-send hook (invoked by {@link AgentSideEffects} before the
* agent locks its subprocess cwd). Resolves the working directories the session
* will actually run in and hands them to the agent at send time:
* - index 0 is the process root: for `worktree` isolation the isolated
* worktree (created here on the first send, see
* {@link _resolveWorktreeBeforeSend}); for `folder` isolation the picked
* folder; `undefined` (whole result) for workspace-less sessions.
* - the tail carries any additional session roots as-is (only index 0 is
* worktree-remapped; additional roots are passed through unchanged).
*/
private async _resolveWorkingDirectoryBeforeSend(params: { session: string; chat: string; turnId: string; prompt: string }): Promise<readonly URI[] | undefined> {
const sessionId = AgentSession.id(params.session);
const pickedFolders = this._configurationService.getEffectiveWorkingDirectories(params.session);
const pickedFolderUri = pickedFolders?.[0] ? URI.parse(pickedFolders[0]) : undefined;
const tail = (pickedFolders ?? []).slice(1).map(d => URI.parse(d));
// Only worktree-isolation sessions defer directory resolution to the first
// send (so the prompt can name the branch); folder / workspace-less
// sessions run directly in the picked folder.
if (!this._worktree.isWorkingDirectoryPending(sessionId)) {
if (!pickedFolderUri) {
return undefined;
}
const resolved = await this._worktree.resolveWorkingDirectoryForResume(URI.parse(params.session), sessionId, pickedFolderUri);
return [resolved, ...tail];
}
// Fall back to the picked folder when worktree creation failed so the
// session still materializes in the user's folder rather than nowhere.
const resolved = await this._resolveWorktreeBeforeSend({ ...params, sessionId, pickedFolderUri }) ?? pickedFolderUri;
return resolved ? [resolved, ...tail] : undefined;
}
private async _resolveChatAttachmentTurns(resource: string): Promise<readonly Turn[]> {
const readTurns = () => {
const state = this._stateManager.getChatState(resource) ?? this._stateManager.getDefaultChatState(resource);
return state?.turns;
};
const existing = readTurns();
if (existing) {
return existing;
}
const sessionUri = URI.parse(isAhpChatChannel(resource) ? parseRequiredSessionUriFromChatUri(resource) : resource);
if (!this._stateManager.getSessionState(sessionUri.toString())) {
await this.restoreSession(sessionUri);
} else {
const provider = this._providerService.getProviderForSession(sessionUri);
if (provider) {
await this._restorePeerChats(provider, sessionUri);
}
}
if (isAhpChatChannel(resource)) {
const state = await this._stateManager.resolveChatState(resource);
if (state) {
return state.turns;
}
throw new Error(`Cannot resolve peer chat attachment: ${resource}`);
}
const resolved = readTurns();
if (resolved) {
return resolved;
}
return [];
}
/**
* Creates the session's isolated worktree on the first send (deferred so the
* user's prompt can name the branch), reports creation progress as the chat's
* activity, surfaces the "Created isolated worktree" announcement as the first
* markdown response part or a durable fallback warning, and returns the created worktree URI.
* Idempotent; safe to call once the worktree exists. Returns `undefined` when
* worktree creation failed. Only invoked for sessions whose worktree is still
* pending (see {@link _resolveWorkingDirectoryBeforeSend}).
*/
private async _resolveWorktreeBeforeSend(params: { session: string; chat: string; turnId: string; prompt: string; sessionId: string; pickedFolderUri: URI | undefined }): Promise<URI | undefined> {
const { sessionId, pickedFolderUri } = params;
const worktree = this._worktree;
let reportedActivity = false;
let failureDiagnostic: string | undefined;
try {
await worktree.resolveOnFirstSend({
sessionUri: URI.parse(params.session),
sessionId,
workingDirectory: pickedFolderUri,
config: this._configurationService.getSessionConfigValues(params.session),
prompt: params.prompt,
githubToken: this._authService.getAuthToken({
resource: this._gitHubEndpointService.getCopilotResource().resource,
scopes: this._gitHubEndpointService.getCopilotResource().scopes_supported,
}),
onProgress: activity => {
reportedActivity = true;
this._stateManager.dispatchServerAction(params.chat, { type: ActionType.ChatActivityChanged, activity });
},
});
} catch (err) {
failureDiagnostic = toErrorMessage(err);
this._logService.warn(`[AgentService] worktree resolution failed for ${params.session}: ${failureDiagnostic}`);
}
// Clear on every exit path so a failed creation can't strand the chat
// on a stale "Creating isolated worktree" activity.
if (reportedActivity) {
this._stateManager.dispatchServerAction(params.chat, { type: ActionType.ChatActivityChanged, activity: undefined });
}
const resolvedWorktree = worktree.getResolvedWorktree(sessionId);
if (!resolvedWorktree) {
try {
await worktree.persistCreationFailure(URI.parse(params.session), sessionId, failureDiagnostic);
} catch (err) {
this._logService.warn(`[AgentService] failed to persist worktree creation failure for ${params.session}: ${toErrorMessage(err)}`);
}
this._stateManager.dispatchServerAction(params.chat, {
type: ActionType.ChatResponsePart,
turnId: params.turnId,
part: buildWorktreeFailureNotification(failureDiagnostic),
});
return undefined;
}
const announcement = worktree.takePendingAnnouncement(sessionId);
if (announcement !== undefined) {
this._stateManager.dispatchServerAction(params.chat, {
type: ActionType.ChatResponsePart,
turnId: params.turnId,
part: { kind: ResponsePartKind.Markdown, id: generateUuid(), content: announcement },
});
}
return resolvedWorktree;
}
private _initializeProvider(provider: IAgent): IDisposable {
const subscriptions = new DisposableStore();
try {