Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
b0d2905
agentHost: relocate Session ownership into the orchestrator (T2/T4)
sandy081 Jul 15, 2026
0068f98
agentHost: drop orchestratorOwnsSession opt-in; chat surface is the s…
sandy081 Jul 15, 2026
a0118d5
agentHost: trim verbose comments on the T2/T4 session-ownership code
sandy081 Jul 15, 2026
43997a1
agentHost: drop redundant session-typed methods from IAgent (Category C)
sandy081 Jul 15, 2026
5613615
agentHost: rename IAgent.getSessionMetadata to getConversationMetadata
sandy081 Jul 16, 2026
cd3a7e7
Merge remote-tracking branch 'origin/main' into agents/multi-chat-ses…
sandy081 Jul 16, 2026
9608b87
agentHost: clarify IAgentConversationMetadata._meta is session-generi…
sandy081 Jul 16, 2026
b79c32a
agentHost: clarify Claude disposeSession takes the agent's own SDK se…
sandy081 Jul 16, 2026
4534471
agentHost: document the AH-session vs SDK-session terminology convention
sandy081 Jul 16, 2026
738eb38
agentHost: make IAgent enumeration session-keyed (orchestrator owns s…
sandy081 Jul 16, 2026
7a8523b
agentHost: stop agents synthesizing default-chat URIs at runtime
sandy081 Jul 16, 2026
d0211eb
agents: reuse orchestrator default-chat URI on the provision path
sandy081 Jul 16, 2026
335ed86
agentHost: flatten provider chat bindings
sandy081 Jul 19, 2026
13f5d62
Merge remote-tracking branch 'origin/main' into agents/multi-chat-ses…
sandy081 Jul 19, 2026
fac8ed5
agentHost: make chat lifecycle exact and retry-safe
sandy081 Jul 20, 2026
fbf6ac1
agentHost: restore subagent transcripts via the chat-surface getMessages
sandy081 Jul 20, 2026
db460de
agentHost: orchestrator owns session provisioning; agents only create…
sandy081 Jul 20, 2026
769991b
agentHost: enable multi-chat for Codex (base for I3-removal branch)
sandy081 Jul 20, 2026
e17c4ea
agentHost: add orchestrator-owned session registry (I3 removal stage 1)
sandy081 Jul 20, 2026
e3aa0b1
agentHost: enumerate sessions from the registry, not providers (I3 re…
sandy081 Jul 20, 2026
a7a36bd
agentHost: clarify Codex is already I3-decoupled (I3 removal stage 3a)
sandy081 Jul 21, 2026
b6ced95
agentHost: collapse fresh-session provisioning into chats.createSessi…
sandy081 Jul 21, 2026
7b9bed8
agentHost: implement chats.createSessionChat in Copilot and Codex (I3…
sandy081 Jul 21, 2026
9a2e26a
Merge remote-tracking branch 'origin/main' into agents/agent-host-i3-…
sandy081 Jul 23, 2026
41c3f40
Merge remote-tracking branch 'origin/main' into agents/agent-host-i3-…
sandy081 Aug 3, 2026
dfe5369
Merge remote-tracking branch 'origin/main' into agents/agent-host-i3-…
sandy081 Aug 4, 2026
623ccb4
Merge remote-tracking branch 'origin/main' into agents/agent-host-i3-…
sandy081 Aug 4, 2026
df662f2
Merge remote-tracking branch 'origin/main' into agents/agent-host-i3-…
sandy081 Aug 4, 2026
4df1a9e
Merge remote-tracking branch 'origin/main' into agents/agent-host-i3-…
sandy081 Aug 4, 2026
51de748
Merge remote-tracking branch 'origin/main' into agents/agent-host-i3-…
sandy081 Aug 5, 2026
5674540
Merge remote-tracking branch 'origin/main' into agents/agent-host-i3-…
sandy081 Aug 7, 2026
e8dea46
Merge remote-tracking branch 'origin/main' into agents/agent-host-i3-…
sandy081 Aug 7, 2026
9565089
agentHost: enable Codex multi-chat capability
sandy081 Aug 7, 2026
31aa3e9
agentHost: allow recording gated multi-chat E2E tests
sandy081 Aug 7, 2026
c219fb3
agentHost: harden session registry and chat lifecycle
sandy081 Aug 7, 2026
cc04df4
Merge remote-tracking branch 'origin/main' into agents/agent-host-i3-…
sandy081 Aug 7, 2026
34d3e45
agentHost: remove Claude default-chat URI inference
sandy081 Aug 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/skills/sessions/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ Then read the relevant spec for the area you are changing (see table below). If

## Common Pitfalls

- **Do not make an agent infer AH chat membership from shared SDK resources**: Agent Host owns the chat catalog, default-chat identity, lifecycle ordering, and config fan-out. Agents should keep flat chat-to-SDK and SDK-id reverse indexes and apply each AH request to exactly the addressed chat; shared resource placement does not justify sibling grouping or cascades.
- **Do not encode AH membership terminology in an agent**: provider records must describe only concrete SDK backing data. Names and branches such as `isPeerChat`, `peerChat`, or provider-side default/session-chat roles leak AH grouping into the agent; route exact chats from their binding instead. If an operation needs session or persistence information, AH passes it as transient context; never retain or derive it from a chat URI.
- **Paired experiment treatments must resolve atomically**: when a prompt and its editable placeholder are separate treatment values, use them only when both are non-empty; otherwise use both defaults so copy from different variants is never mixed. The prompt may omit the placeholder token entirely, in which case it is used literally and placeholder highlighting is simply absent.

- **Onboarding variations share structural steps and vary only their run step**: keep one scenario for workspace selection, then resolve the experiment/developer variation when the run step executes. Personalized GitHub prompts use existing authentication silently, stay within a bounded cancellable lookup, verify that the selected draft workspace is still current, and fall back to the default prompt without surfacing an error.
Expand Down
203 changes: 174 additions & 29 deletions src/vs/platform/agentHost/MULTI_CHAT_ARCHITECTURE.md

Large diffs are not rendered by default.

182 changes: 125 additions & 57 deletions src/vs/platform/agentHost/common/agentService.ts

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions src/vs/platform/agentHost/node/agentChatBackings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

export { decodeProviderData, encodeProviderData } from './agentPeerChats.js';
export type { IPersistedChat, IPersistedSideChat } from './agentPeerChats.js';
25 changes: 21 additions & 4 deletions src/vs/platform/agentHost/node/agentHostStateManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -535,17 +535,34 @@ export class AgentHostStateManager extends Disposable {
getOverlaySessionSummaries(): SessionSummary[] {
const summaries: SessionSummary[] = [];
for (const [key, entry] of this._sessionStates) {
// Turn activity lives on the session's default chat after the
// multi-chat protocol move, so consult that chat's turns/activeTurn.
const chat = this._chatEntries.get(buildDefaultChatUri(key))?.state;
if (entry.state.lifecycle === SessionLifecycle.Creating && !chat?.activeTurn && (chat?.turns.length ?? 0) === 0) {
if (this._isIdleProvisional(key, entry.state.lifecycle)) {
continue;
}
summaries.push(this._toSummary(key, entry));
}
return summaries;
}

/**
* Whether a session is idle and still provisional: created but not yet
* materialized (lifecycle {@link SessionLifecycle.Creating}) and with no turn
* activity (no active turn, no recorded turns). Such sessions — e.g. the
* new-session composer's eagerly-created session before its first message —
* must not leak into the session list (#321269). Returns `false` for a
* session with no tracked state (nothing to suppress).
*/
isIdleProvisionalSession(session: string): boolean {
const entry = this._sessionStates.get(session);
return entry ? this._isIdleProvisional(session, entry.state.lifecycle) : false;
}

private _isIdleProvisional(session: string, lifecycle: SessionLifecycle): boolean {
// Turn activity lives on the session's default chat after the multi-chat
// protocol move, so consult that chat's turns/activeTurn.
const chat = this._chatEntries.get(buildDefaultChatUri(session))?.state;
return lifecycle === SessionLifecycle.Creating && !chat?.activeTurn && (chat?.turns.length ?? 0) === 0;
}

/**
* Returns all session URIs whose keys start with the given prefix.
* Used to discover subagent sessions for a given parent.
Expand Down
389 changes: 347 additions & 42 deletions src/vs/platform/agentHost/node/agentService.ts

Large diffs are not rendered by default.

210 changes: 210 additions & 0 deletions src/vs/platform/agentHost/node/agentSessionRegistry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { Disposable, IReference, toDisposable } from '../../../base/common/lifecycle.js';
import { URI } from '../../../base/common/uri.js';
import { ILogService } from '../../log/common/log.js';
import { AgentProvider } from '../common/agentService.js';
import { ISessionDatabase, ISessionDataService } from '../common/sessionDataService.js';

/** A session recorded in the orchestrator-owned {@link AgentSessionRegistry}. */
export interface IRegisteredSession {
readonly session: URI;
readonly provider: AgentProvider;
/** Session creation time (ms since epoch) as first observed by the orchestrator. */
readonly startTime: number;
}

interface IPersistedRegistryEntry {
readonly provider: AgentProvider;
readonly startTime: number;
}

/** The persisted registry blob. */
interface IPersistedRegistry {
readonly version: 1;
/** Whether the one-time provider backfill has run for this host. */
readonly backfilled: boolean;
readonly sessions: Record<string, IPersistedRegistryEntry>;
}

interface IRegistryState {
readonly sessions: Map<string, IPersistedRegistryEntry>;
backfilled: boolean;
}

/**
* The reserved URI whose per-session database backs the registry index. Its
* scheme (`agent-host-registry`) cannot collide with a real session URI, which
* always carries a provider scheme (`copilot`/`claude`/`codex`/`copilotcli`).
*/
const REGISTRY_URI = URI.from({ scheme: 'agent-host-registry', path: '/sessions' });
const REGISTRY_METADATA_KEY = 'sessionRegistry';

/**
* A durable, orchestrator-owned index of the sessions that exist, keyed by
* session URI. Unlike the agents' `listSessions()` (which enumerates their own
* SDK sessions/threads and maps them to session URIs via invariant I3), this
* registry is authoritative on the AH side and does not depend on the agent
* exposing a session whose SDK id equals the session id.
*
* Persisted as a single JSON blob in a reserved session database, with
* serialized read-modify-write (mirroring the peer-chat catalog) so concurrent
* register/unregister calls never clobber each other.
*
* Stage 1 (this component) is purely additive: it is populated alongside the
* existing create/delete paths and validated against the live `listSessions`
* output, but does NOT yet drive enumeration.
*/
export class AgentSessionRegistry extends Disposable {

private _dbRef: IReference<ISessionDatabase> | undefined;
/** In-memory mirror of the persisted index; the source of truth once loaded. */
private _cache: Map<string, IPersistedRegistryEntry> | undefined;
/** Whether the one-time provider backfill has run; part of the persisted blob. */
private _backfilled = false;
/** Serializes read-modify-write of the persisted blob. */
private _writeChain: Promise<void> = Promise.resolve();
private _loadPromise: Promise<Map<string, IPersistedRegistryEntry>> | undefined;

constructor(
private readonly _sessionDataService: ISessionDataService,
private readonly _logService: ILogService,
) {
super();
this._register(toDisposable(() => this._dbRef?.dispose()));
}

/** Record (or refresh) a session in the registry. Idempotent per session URI. */
async register(session: URI, provider: AgentProvider, startTime: number): Promise<void> {
await this._enqueueWrite(state => {
const key = session.toString();
const existing = state.sessions.get(key);
// Preserve the first-observed startTime so a later re-register (e.g.
// a reconnect issuing createSession again) never rewrites it.
state.sessions.set(key, { provider, startTime: existing?.startTime ?? startTime });
});
}

/** Remove a session from the registry (true delete). No-op if absent. */
async unregister(session: URI): Promise<void> {
await this._enqueueWrite(state => {
state.sessions.delete(session.toString());
});
}

/** Every session currently recorded, in no particular order. */
async list(): Promise<IRegisteredSession[]> {
const cache = await this._load();
const result: IRegisteredSession[] = [];
for (const [key, entry] of cache) {
result.push({ session: URI.parse(key), provider: entry.provider, startTime: entry.startTime });
}
return result;
}

/** Whether the registry has ever been populated (used to gate one-time backfill). */
async isEmpty(): Promise<boolean> {
return (await this._load()).size === 0;
}

/**
* Whether the one-time provider backfill has completed for this host. Gated
* by a persisted marker rather than emptiness so a registry that a
* `createSession` has already populated is still backfilled from the legacy
* provider enumeration exactly once.
*/
async isBackfilled(): Promise<boolean> {
await this._load();
return this._backfilled;
}

/** Records that the one-time provider backfill has completed. */
async markBackfilled(): Promise<void> {
await this._enqueueWrite(state => {
state.backfilled = true;
});
}

private _enqueueWrite(mutate: (state: IRegistryState) => void): Promise<void> {
const next = this._writeChain
.catch(() => { /* a failed prior write must not block later ones */ })
.then(async () => {
const cache = await this._load();
const state: IRegistryState = {
sessions: new Map(cache),
backfilled: this._backfilled,
};
mutate(state);
await this._persist(state);
this._cache = state.sessions;
this._backfilled = state.backfilled;
});
this._writeChain = next.catch(() => { /* keep the chain alive */ });
return next;
}

private _load(): Promise<Map<string, IPersistedRegistryEntry>> {
if (this._cache) {
return Promise.resolve(this._cache);
}
if (!this._loadPromise) {
const load = this._doLoad();
this._loadPromise = load;
void load.then(undefined, () => {
if (this._loadPromise === load) {
this._loadPromise = undefined;
}
});
}
return this._loadPromise;
}

private async _doLoad(): Promise<Map<string, IPersistedRegistryEntry>> {
const cache = new Map<string, IPersistedRegistryEntry>();
try {
const raw = await this._db().getMetadata(REGISTRY_METADATA_KEY);
if (raw !== undefined) {
const parsed = JSON.parse(raw) as Partial<IPersistedRegistry>;
if (parsed && typeof parsed === 'object') {
this._backfilled = parsed.backfilled === true;
const sessions = parsed.sessions;
if (sessions && typeof sessions === 'object') {
for (const [key, value] of Object.entries(sessions)) {
const entry = value as Partial<IPersistedRegistryEntry>;
if (entry && typeof entry.provider === 'string' && typeof entry.startTime === 'number') {
cache.set(key, { provider: entry.provider, startTime: entry.startTime });
}
}
}
}
}
} catch (err) {
this._logService.warn(`[AgentSessionRegistry] Failed to load registry: ${err instanceof Error ? err.message : String(err)}`);
throw err;
}
this._cache = cache;
return cache;
}

private async _persist(state: IRegistryState): Promise<void> {
const sessions: Record<string, IPersistedRegistryEntry> = {};
for (const [key, entry] of state.sessions) {
sessions[key] = entry;
}
const blob: IPersistedRegistry = { version: 1, backfilled: state.backfilled, sessions };
try {
await this._db().setMetadata(REGISTRY_METADATA_KEY, JSON.stringify(blob));
} catch (err) {
this._logService.warn(`[AgentSessionRegistry] Failed to persist registry: ${err instanceof Error ? err.message : String(err)}`);
throw err;
}
Comment thread
sandy081 marked this conversation as resolved.
}

private _db(): ISessionDatabase {
this._dbRef ??= this._sessionDataService.openDatabase(REGISTRY_URI);
return this._dbRef.object;
}
}
40 changes: 29 additions & 11 deletions src/vs/platform/agentHost/node/agentSideEffects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import type { SessionMode } from '../common/agentHostSchema.js';
import { AgentHostClientType } from '../common/agentHostClientInfo.js';
import { AgentHostLaunchKind, createUnknownAgentHostClientTelemetryContext, type IAgentHostClientTelemetryContext } from '../common/agentHostTelemetry.js';
import { readAgentModelByokIdentifier } from '../common/agentModelByokMeta.js';
import { AgentSession, AgentSignal, IAgent, IAgentToolPendingConfirmationSignal } from '../common/agentService.js';
import { AgentSession, AgentSignal, IAgent, IAgentChatContext, IAgentToolPendingConfirmationSignal } from '../common/agentService.js';
import { readToolCallMeta, toToolCallMeta } from '../common/meta/agentToolCallMeta.js';

import { ITelemetryService } from '../../telemetry/common/telemetry.js';
Expand All @@ -32,6 +32,7 @@ import { buildOpenSessionLinkForChatResource } from '../common/openSessionLink.j
import { SessionInputRequestKind, ToolCallContributorKind, type AgentInfo, type SessionInputRequest } from '../common/state/protocol/state.js';
import { ActionType, isChatAction, StateAction, type ChatAction, type ChatToolCallCompleteAction } from '../common/state/sessionActions.js';
import {
buildDefaultChatUri,
buildSubagentChatUri,
chatStorageUri,
getToolFileEdits,
Expand All @@ -49,6 +50,7 @@ import {
PendingMessageKind,
ResponsePartKind,
ROOT_STATE_URI,
resolveChatUri,
SessionLifecycle,
SessionStatus,
ToolCallStatus,
Expand Down Expand Up @@ -348,6 +350,12 @@ export class AgentSideEffects extends Disposable {
}));
}

private _chatContext(session: ProtocolURI, chat: ProtocolURI): IAgentChatContext {
const sessionUri = URI.parse(session);
const chatUri = URI.parse(chat);
return { session: sessionUri, resource: resolveChatUri(sessionUri, chatUri) };
}

/**
* Publishes agent descriptors using the last known model lists.
*/
Expand Down Expand Up @@ -1465,7 +1473,7 @@ export class AgentSideEffects extends Disposable {
: action.turnId;
// Route to the chat being truncated: the default chat (addressed
// by the session) or a peer chat with its own backing.
agent?.truncateSession?.(URI.parse(sessionChannel), sdkTurnId, URI.parse(chatChannel)).catch(err => {
agent?.truncateSession?.(URI.parse(sessionChannel), sdkTurnId, URI.parse(chatChannel), this._chatContext(sessionChannel, chatChannel)).catch(err => {
this._logService.error('[AgentSideEffects] truncateSession failed', err);
});
// Drop persisted local turns that no longer survive in the
Expand Down Expand Up @@ -1557,7 +1565,17 @@ export class AgentSideEffects extends Disposable {
// forward a live, session-mutable change (e.g. Claude's
// `permissionMode`) to its running SDK without re-entering its own
// tool callbacks.
this._options.getAgent(channel)?.onSessionConfigChanged?.(URI.parse(channel), values ?? {});
const agent = this._options.getAgent(channel);
if (agent?.onChatConfigChanged) {
const chats = sessionState?.chats.length
? sessionState.chats.map(chat => URI.parse(chat.resource))
: [URI.parse(buildDefaultChatUri(channel))];
for (const chat of chats) {
agent.onChatConfigChanged(chat, values ?? {});
}
} else {
agent?.onSessionConfigChanged?.(URI.parse(channel), values ?? {});
}
break;
}
case ActionType.ChatToolCallComplete: {
Expand Down Expand Up @@ -1860,27 +1878,27 @@ export class AgentSideEffects extends Disposable {
let failureStage: AgentHostTurnFailureStage = 'workingDirectory';
try {
// Host-owned working-directory resolution: resolve the session's working
// directories before the agent materializes, so the agent runs in
// index 0 (the process root) without ever knowing how it was derived.
// Index 0 is the created worktree for worktree sessions (created here on
// the first send) or the picked folder for folder sessions; undefined for
// workspace-less sessions. Any additional roots follow index 0.
// directory before the agent materializes, so the agent runs in it
// without ever knowing how it was derived. Returns the created worktree
// for worktree sessions (created here on the first send) or the picked
// folder for folder sessions; undefined for workspace-less sessions.
const resolvedWorkingDirectories = await this._options.resolveWorkingDirectoryBeforeSend?.({ session: options.sessionChannel, chat, turnId, prompt: message.text });
const chatContext = this._chatContext(options.sessionChannel, chat);

const selectionUpdates: Promise<void>[] = [];
if (message.model) {
failureStage = 'modelSelection';
selectionUpdates.push(agent.chats.changeModel(chatUri, message.model));
selectionUpdates.push(agent.chats.changeModel(chatUri, message.model, chatContext));
}
selectionUpdates.push(agent.chats.changeAgent(chatUri, message.agent).catch(err => {
selectionUpdates.push(agent.chats.changeAgent(chatUri, message.agent, chatContext).catch(err => {
this._logService.error('[AgentSideEffects] changeAgent failed', err);
}));

await Promise.all(selectionUpdates);

failureStage = 'sendMessage';
const resolvedAttachments = await this._resolveChatAttachments(message.attachments);
await agent.chats.sendMessage(chatUri, message.text, resolvedWorkingDirectories, resolvedAttachments, turnId, senderClientId, clientType);
await agent.chats.sendMessage(chatUri, message.text, resolvedWorkingDirectories, resolvedAttachments, turnId, senderClientId, clientType, chatContext);
} catch (err) {
const failure = buildTurnFailure(failureStage, err);
const error = failure.error;
Expand Down
Loading
Loading