Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
13 changes: 12 additions & 1 deletion src/vs/sessions/SESSIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ The sessions system is organized in three layers, each with stricter import perm
Defines the foundational interfaces that all providers and consumers share:

- **`ISession`** (`session.ts`) — Universal session facade. A self-contained observable object representing a session; consumers never reach back to provider internals. Each session has a globally unique ID built via `toSessionId(providerId, resource)` and groups one or more `IChat` instances.
- **`ISessionsProvider`** (`sessionsProvider.ts`) — Contract every provider implements. Covers workspace discovery, session CRUD, sending requests, model enumeration/selection/presentation (`getModels`, `getModelPickerOptions`, `onDidChangeModels`, `setModel`), and firing change events.
- **`ISessionsProvider`** (`sessionsProvider.ts`) — Contract every provider implements. Covers workspace discovery, session CRUD, sending requests, model enumeration/selection/presentation (`getModels`, `getModelPickerOptions`, `onDidChangeModels`, `setModel`), optional fork adoption for providers with self-managed session lists, and firing change events.
- **`ISessionsManagementService`** (`sessionsManagement.ts`) — The session **model** service. Aggregates sessions from all providers, owns the canonical `activeSession` (+ `setActiveSession`, called by the view), the pending new-session draft (`createNewSession`/`isNewChatSession`), send (`sendNewChatRequest`/`createAndSendNewChatRequest`/`sendRequest`), CRUD (archive/delete/rename), recency history, and the active-session context keys. It performs **no** view/layout mutation and never imports the core view or part.

> **Model vs view.** Opening sessions, the visible-session slots and their arrangement, focus, Back/Forward navigation, and per-session view persistence live in **`ISessionsViewService`** (core — see `browser/sessionsViewService.ts`), not the management service. The split mirrors `IEditorService.activeEditor` (model) vs `IEditorGroupsService.activeGroup` + focus (view). See [Model vs View](#model-vs-view-session-services).
Expand Down Expand Up @@ -224,6 +224,17 @@ purely a management/UI concern. In the new-session composer the gesture is
the foreground. The background gesture is only offered for the new-session
composer, not when sending a new chat within an existing session.

### Forking a Local Chat

The shared workbench chat fork action owns conversation cloning: it serializes
the source chat, truncates to the selected checkpoint, cleans transient request
state, and creates a new raw `vscode-local-chat` model. Normal Chat can open
that raw resource directly. In the Agents window, the forked resource must first
become a provider-owned `ISession`; `SessionsManagementService.adoptForkedChat`
resolves the source chat to its owning session/provider and calls the optional
`ISessionsProvider.adoptForkedChat` hook when the provider needs to materialize
the already-created chat resource into its session list.

For callers outside the new-session composer,
`createAndSendNewChatRequest(folderUri, options, createOptions?)` creates a fresh
session for the folder and sends the request in one call, **without** touching
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,15 @@ A `MutableDisposable` on `LocalSession` ensures repeated `trackModel` calls don'
- **`setModel`** — only meaningful for the current new session before send; updates pre-send model id.
- **`createNewChat`** — for the current new session, returns the already-prepared `IChat` and updates `mainChat`. For an existing committed session, creates a subsequent (child) chat linked to the primary via `parentResource`.
- **`deleteChat`** — removes a single child chat from a multi-chat session after a confirmation dialog; deleting the primary (or the last remaining chat) removes the whole session. An unknown/stale chat URI is a no-op.
- **`adoptForkedChat`** — materializes a forked local chat model created by the shared chat fork action into this provider's session list. The fork becomes a new primary local session, is persisted in `sessions.localChat.sessions`, and is surfaced through `onDidChangeSessions` so the Agents window can open it through `ISessionsManagementService`.

## Forking

Local chat fork data is still produced by the shared workbench fork action (`ForkConversationAction`): it serializes the source chat, truncates to the selected checkpoint, cleans transient request state, generates fresh IDs/timestamps, and creates a new `vscode-local-chat` model via `IChatService.loadSessionFromData()`.

Normal Chat can open that raw chat resource directly. The Agents window cannot: it needs every visible chat to belong to an `ISession` owned by a sessions provider. `adoptForkedChat` is the bridge between those two models. It takes the already-created forked chat resource, wraps it in a `LocalSession`, adds it to `_sessionCache`, persists provider metadata, and fires `added` before live model tracking emits later `changed` events.

Forking from any chat in a multi-chat local session creates a standalone primary session. It does not add the fork as another child chat of the original session.

## Multi-Chat Support

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,22 @@ registerAction2(class extends ForkConversationAction {
const sessionsViewService = accessor.get(ISessionsViewService);
const logService = accessor.get(ILogService);

const parentSession = sessionsManagementService.getSession(parentSessionResource);
const parentSession = sessionsManagementService.getSessionForChatResource(parentSessionResource);
if (!parentSession) {
logService.error(`Parent session ${parentSessionResource.toString()} not found when forking conversation`);
return super._openForkedSession(instantiationService, parentSessionResource, forkedSessionResource);
}

try {
const adoptedSession = await sessionsManagementService.adoptForkedChat(parentSessionResource, forkedSessionResource);
if (adoptedSession) {
await sessionsViewService.openSession(adoptedSession.resource);
return;
}
} catch (error) {
logService.error(`Failed to adopt forked chat ${forkedSessionResource.toString()}`, error);
}
Comment thread
Chulong-Li marked this conversation as resolved.

// Wait for the forked session to appear, but bound the wait so a
// missing session does not hang forever. Applies to local and
// contributed (agent-host) sessions alike — both surface via
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { autorun, constObservable, IObservable, IReader, ISettableObservable, ob
import { ThemeIcon } from '../../../../../base/common/themables.js';
import { URI, UriComponents } from '../../../../../base/common/uri.js';
import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';
import { IChatService, IChatSendRequestOptions, IChatDetail, convertLegacyChatSessionTiming } from '../../../../../workbench/contrib/chat/common/chatService/chatService.js';
import { IChatService, IChatSendRequestOptions, IChatDetail, convertLegacyChatSessionTiming, ResponseModelState } from '../../../../../workbench/contrib/chat/common/chatService/chatService.js';
import { IChatSessionFileChange2, IChatSessionProviderOptionItem, SessionType } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js';
import { ISession, IChat, ISessionGitRepository, ISessionFolder, ISessionWorkspace, SessionStatus, ISessionType, ISessionFileChange, toSessionId, SESSION_WORKSPACE_GROUP_LOCAL, IChatCheckpoints } from '../../../../services/sessions/common/session.js';
import { ChatAgentLocation, ChatConfiguration, ChatModeKind, ChatPermissionLevel, isChatPermissionLevel } from '../../../../../workbench/contrib/chat/common/constants.js';
Expand Down Expand Up @@ -373,6 +373,12 @@ class LocalSession extends Disposable {
}
}

setModeSnapshot(mode: { readonly id: string; readonly kind: ChatModeKind | undefined } | undefined): void {
this._mode = undefined;
// The sessions surface stores a concrete mode kind; local chats default to agent mode when input state has no kind.
this._modeObservable.set(mode ? { id: mode.id, kind: mode.kind ?? ChatModeKind.Agent } : undefined, undefined);
}
Comment thread
Chulong-Li marked this conversation as resolved.

/**
* Update this session from a persisted history detail.
*/
Expand Down Expand Up @@ -569,7 +575,7 @@ export class LocalChatSessionsProvider extends Disposable implements ISessionsPr
lastMessageDate: stored.lastMessageDate,
timing: { created: stored.createdAt, lastRequestStarted: undefined, lastRequestEnded: stored.lastMessageDate },
isActive: false,
lastResponseState: 0 /* ResponseModelState.Complete */,
lastResponseState: ResponseModelState.Complete,
workingDirectory,
};

Expand Down Expand Up @@ -872,6 +878,78 @@ export class LocalChatSessionsProvider extends Disposable implements ISessionsPr
throw new Error(`Session '${sessionId}' not found or is not the current new session`);
}

async adoptForkedChat(sessionId: string, sourceChatUri: URI, forkedChatUri: URI): Promise<ISession> {
if (this._sessionCache.has(forkedChatUri.toString())) {
return this._toISession(this._sessionCache.get(forkedChatUri.toString())!);
}

const source = this._findSession(sessionId);
if (!source) {
throw new Error(`Session '${sessionId}' not found`);
}

const primary = this._resolvePrimary(source);
const sourceChat = this._getGroupChats(primary).find(chat => isEqual(chat.resource, sourceChatUri));
if (!sourceChat) {
throw new Error(`Chat resource ${sourceChatUri.toString()} is not part of session '${sessionId}'`);
}

const modelRef = this.chatService.acquireExistingSession(forkedChatUri, 'LocalChatSessionsProvider#adoptForkedChat')
?? await this.chatService.acquireOrLoadSession(forkedChatUri, ChatAgentLocation.Chat, CancellationToken.None, 'LocalChatSessionsProvider#adoptForkedChat');
if (!modelRef) {
throw new Error(`Forked chat resource ${forkedChatUri.toString()} could not be loaded`);
}

try {
const model = modelRef.object;
const sourceWorkspace = primary.workspace.get();
const modelWorkingDirectory = model.workingDirectory;
const workingDirectory = modelWorkingDirectory ?? sourceWorkspace?.folders[0]?.root;
const workspace = (modelWorkingDirectory ? this.resolveWorkspace(modelWorkingDirectory) : undefined)
?? sourceWorkspace
?? (workingDirectory ? this.resolveWorkspace(workingDirectory) : undefined);
if (!workspace) {
throw new Error(`Cannot resolve workspace for forked chat ${forkedChatUri.toString()}`);
}

if (!model.workingDirectory && workingDirectory) {
model.setWorkingDirectory(workingDirectory);
}

const timing = model.timing;
const lastUpdate = model.lastMessageDate || timing.lastRequestEnded || timing.lastRequestStarted || timing.created;
const requestInProgress = model.requestInProgress.get();
const detail: IChatDetail = {
sessionResource: forkedChatUri,
title: model.title,
lastMessageDate: lastUpdate,
timing,
isActive: requestInProgress,
lastResponseState: requestInProgress ? ResponseModelState.Pending : ResponseModelState.Complete,
workingDirectory,
};

const session = LocalSession.fromHistory(detail, this.id, workspace, this.instantiationService);
session.setStatus(requestInProgress ? SessionStatus.InProgress : SessionStatus.Completed);
const inputState = model.inputModel.state.get();
session.setModelId(inputState?.selectedModel?.identifier);
session.setModeSnapshot(inputState?.mode);
if (inputState?.permissionLevel) {
session.setPermissionLevel(inputState.permissionLevel);
}

this._sessionCache.set(session.resource.toString(), session);
this._addStoredSession(session);

const forkedSession = this._toISession(session);
this._onDidChangeSessions.fire({ added: [forkedSession], removed: [], changed: [] });
this._syncSessionFromModel(session);
return forkedSession;
} finally {
modelRef.dispose();
}
}

/**
* Creates a subsequent chat within an existing multi-chat session. The new
* chat is linked to the primary chat via {@link LocalSession.parentResource}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { ILogService, NullLogService } from '../../../../../../platform/log/comm
import { IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js';
import { TestStorageService } from '../../../../../../workbench/test/common/workbenchTestServices.js';
import { ChatAgentLocation } from '../../../../../../workbench/contrib/chat/common/constants.js';
import { IChatModel } from '../../../../../../workbench/contrib/chat/common/model/chatModel.js';
import { IChatModel, IChatModelInputState, IInputModel, ISerializableChatModelInputState } from '../../../../../../workbench/contrib/chat/common/model/chatModel.js';
import { IChatModelReference, IChatService, IChatSessionStartOptions, IChatSessionTiming } from '../../../../../../workbench/contrib/chat/common/chatService/chatService.js';
import { ILanguageModelsService } from '../../../../../../workbench/contrib/chat/common/languageModels.js';
import { ILanguageModelToolsService } from '../../../../../../workbench/contrib/chat/common/tools/languageModelToolsService.js';
Expand All @@ -31,15 +31,24 @@ import { LocalChatSessionsProvider, LocalSessionType, LOCAL_SESSION_ENABLED_SETT

// ---- Mock chat service ----------------------------------------------------

function createMockModel(sessionResource: URI, opts?: { title?: string; requestInProgress?: IObservable<boolean>; timing?: IChatSessionTiming }): IChatModel {
let workingDirectory: URI | undefined;
function createMockModel(sessionResource: URI, opts?: { title?: string; requestInProgress?: IObservable<boolean>; timing?: IChatSessionTiming; inputState?: IChatModelInputState; workingDirectory?: URI }): IChatModel {
let workingDirectory: URI | undefined = opts?.workingDirectory;
const requestInProgress = opts?.requestInProgress ?? observableValue<boolean>('requestInProgress', false);
const timing: IChatSessionTiming = opts?.timing ?? { created: 1_000, lastRequestStarted: undefined, lastRequestEnded: undefined };
const inputState = observableValue<IChatModelInputState | undefined>('inputState', opts?.inputState);
const inputModel: IInputModel = {
state: inputState,
setState: state => inputState.set({ ...inputState.get(), ...state } as IChatModelInputState, undefined),
clearState: () => inputState.set(undefined, undefined),
toJSON: (): ISerializableChatModelInputState | undefined => undefined,
};
return new class extends mock<IChatModel>() {
override readonly sessionResource = sessionResource;
override readonly title = opts?.title ?? 'Test Session';
override readonly timing = timing;
override readonly lastMessageDate = timing.lastRequestEnded ?? timing.lastRequestStarted ?? timing.created;
override readonly requestInProgress = requestInProgress;
override readonly inputModel = inputModel;
override get workingDirectory() { return workingDirectory; }
override setWorkingDirectory(uri: URI | undefined): void { workingDirectory = uri; }
}();
Expand Down Expand Up @@ -71,12 +80,17 @@ class MockChatService extends Disposable {
return this._models.get(resource.toString());
}

acquireExistingSession(resource: URI): IChatModelReference | undefined {
const model = this.getSession(resource);
return model ? { object: model, dispose: () => { } } : undefined;
}

registerModel(model: IChatModel): void {
this._models.set(model.sessionResource.toString(), model);
}

async acquireOrLoadSession(): Promise<IChatModelReference | undefined> {
return undefined;
async acquireOrLoadSession(resource: URI): Promise<IChatModelReference | undefined> {
return this.acquireExistingSession(resource);
}

async sendRequest(resource: URI, query: string) {
Expand Down Expand Up @@ -142,6 +156,7 @@ const STORAGE_KEY_SESSIONS = 'sessions.localChat.sessions';

interface IReadStoredSession {
readonly uri: UriComponents;
readonly workingDirectory?: UriComponents;
readonly parentUri?: UriComponents;
}
Comment thread
Chulong-Li marked this conversation as resolved.

Expand Down Expand Up @@ -498,4 +513,68 @@ suite('LocalChatSessionsProvider', () => {
childInProgress.set(false, undefined);
assert.strictEqual(group.status.get(), SessionStatus.Completed);
});

test('adoptForkedChat materializes a forked local chat as a provider-owned session', async () => {
const store = leaks.add(new DisposableStore());
const { instantiationService, chatService, storage } = createFixture(store);
const provider = store.add(instantiationService.createInstance(LocalChatSessionsProvider));

const source = await commitNewSession(provider);
const forkedWorkingDirectory = URI.file('/forked/folder');
const forkedResource = URI.parse('vscode-local-chat://chat/forked-primary');
chatService.registerModel(createMockModel(forkedResource, {
title: 'Forked: hello',
timing: { created: 2_000, lastRequestStarted: 2_100, lastRequestEnded: 2_200 },
workingDirectory: forkedWorkingDirectory,
}));

const adopted = await provider.adoptForkedChat(source.sessionId, source.resource, forkedResource);

const stored = readStoredSessions(storage);
const storedFork = stored.find(s => URI.revive(s.uri).toString() === forkedResource.toString());
assert.deepStrictEqual({
adopted: adopted.resource.toString(),
sessions: provider.getSessions().map(session => session.resource.toString()),
chats: adopted.chats.get().map(chat => chat.resource.toString()),
storedParent: storedFork?.parentUri ? URI.revive(storedFork.parentUri).toString() : undefined,
storedWorkingDirectory: storedFork?.workingDirectory ? URI.revive(storedFork.workingDirectory).toString() : undefined,
}, {
adopted: forkedResource.toString(),
sessions: [source.resource.toString(), forkedResource.toString()],
chats: [forkedResource.toString()],
storedParent: undefined,
storedWorkingDirectory: forkedWorkingDirectory.toString(),
});
});

test('adoptForkedChat from a child chat creates a standalone primary session', async () => {
const store = leaks.add(new DisposableStore());
const { instantiationService, chatService, storage } = createFixture(store);
const provider = store.add(instantiationService.createInstance(LocalChatSessionsProvider));

const source = await commitNewSession(provider);
const childResource = await addChat(provider, source);
const forkedResource = URI.parse('vscode-local-chat://chat/forked-child');
chatService.registerModel(createMockModel(forkedResource, {
title: 'Forked: second',
workingDirectory: TEST_FOLDER,
}));

const adopted = await provider.adoptForkedChat(source.sessionId, childResource, forkedResource);

const storedFork = readStoredSessions(storage).find(s => URI.revive(s.uri).toString() === forkedResource.toString());
assert.deepStrictEqual({
adopted: adopted.resource.toString(),
sessionCount: provider.getSessions().length,
originalChatCount: provider.getSessions().find(session => session.resource.toString() === source.resource.toString())?.chats.get().length,
forkChatCount: adopted.chats.get().length,
storedParent: storedFork?.parentUri ? URI.revive(storedFork.parentUri).toString() : undefined,
}, {
adopted: forkedResource.toString(),
sessionCount: 2,
originalChatCount: 2,
forkChatCount: 1,
storedParent: undefined,
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,26 @@ export class SessionsManagementService extends Disposable implements ISessionsMa
);
}

getSessionForChatResource(chatResource: URI): ISession | undefined {
for (const session of this.getSessions()) {
if (this.uriIdentityService.extUri.isEqual(session.resource, chatResource)) {
return session;
}
if (session.chats.get().some(chat => this.uriIdentityService.extUri.isEqual(chat.resource, chatResource))) {
return session;
}
}
return undefined;
}

async adoptForkedChat(sourceChatUri: URI, forkedChatUri: URI): Promise<ISession | undefined> {
const sourceSession = this.getSessionForChatResource(sourceChatUri);
if (!sourceSession) {
return undefined;
}
const provider = this._getProvider(sourceSession);
return provider?.adoptForkedChat?.(sourceSession.sessionId, sourceChatUri, forkedChatUri);
}
getAllSessionTypes(): ISessionType[] {
return [...this._sessionTypes];
}
Expand Down
Loading
Loading