Skip to content

Commit d527b0f

Browse files
authored
Merge pull request #309669 from microsoft/connor4312/proper-forking
agentHost: use proper sdk-provided forking/truncation methods
2 parents 7494c19 + 935c7b7 commit d527b0f

14 files changed

Lines changed: 321 additions & 1386 deletions

File tree

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

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -101,8 +101,19 @@ export interface IAgentCreateSessionConfig {
101101
readonly session?: URI;
102102
readonly workingDirectory?: URI;
103103
readonly config?: Record<string, string>;
104-
/** Fork from an existing session at a specific turn index. */
105-
readonly fork?: { readonly session: URI; readonly turnIndex: number };
104+
/** Fork from an existing session at a specific turn. */
105+
readonly fork?: {
106+
readonly session: URI;
107+
readonly turnIndex: number;
108+
readonly turnId: string;
109+
/**
110+
* Maps old protocol turn IDs to new protocol turn IDs.
111+
* Populated by the service layer after generating fresh UUIDs
112+
* for the forked session's turns. Used by the agent to remap
113+
* per-turn data (e.g. SDK event ID mappings) in the session database.
114+
*/
115+
readonly turnIdMapping?: ReadonlyMap<string, string>;
116+
};
106117
}
107118

108119
export const AgentHostSessionConfigBranchNameHintKey = 'branchNameHint';
@@ -406,21 +417,11 @@ export interface IAgent {
406417
authenticate(resource: string, token: string): Promise<boolean>;
407418

408419
/**
409-
* Truncate a session's history. If `turnIndex` is provided (0-based), keeps
410-
* turns up to and including that turn. If omitted, all turns are removed.
420+
* Truncate a session's history. If `turnId` is provided, keeps turns up to
421+
* and including that turn. If omitted, all turns are removed.
411422
* Optional — not all providers support truncation.
412423
*/
413-
truncateSession?(session: URI, turnIndex?: number): Promise<void>;
414-
415-
/**
416-
* Fork a session at a specific turn, creating a new session on disk
417-
* with the source session's history up to and including the specified turn.
418-
* Optional — not all providers support forking.
419-
*
420-
* @param turnIndex 0-based turn index to fork at.
421-
* @returns The new session's raw ID.
422-
*/
423-
forkSession?(sourceSession: URI, newSessionId: string, turnIndex: number): Promise<void>;
424+
truncateSession?(session: URI, turnId?: string): Promise<void>;
424425

425426
/**
426427
* Receives client-provided customization refs and syncs them (e.g. copies

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

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ import type { FileEditKind } from './state/sessionState.js';
1010

1111
export const ISessionDataService = createDecorator<ISessionDataService>('sessionDataService');
1212

13+
/** Filename of the per-session SQLite database. */
14+
export const SESSION_DB_FILENAME = 'session.db';
15+
1316
// ---- File-edit types ----------------------------------------------------
1417

1518
/**
@@ -68,6 +71,48 @@ export interface ISessionDatabase extends IDisposable {
6871
*/
6972
deleteTurn(turnId: string): Promise<void>;
7073

74+
/**
75+
* Associates a Copilot SDK event ID with a turn. The event ID corresponds
76+
* to the `user.message` event in the SDK event stream and is used by
77+
* the SDK's `history.truncate` and `sessions.fork` RPCs.
78+
*/
79+
setTurnEventId(turnId: string, eventId: string): Promise<void>;
80+
81+
/**
82+
* Retrieves the SDK event ID previously stored for a turn.
83+
* Returns `undefined` if no event ID has been set.
84+
*/
85+
getTurnEventId(turnId: string): Promise<string | undefined>;
86+
87+
/**
88+
* Returns the SDK event ID of the turn inserted immediately after the
89+
* given turn, or `undefined` if the given turn is the last one.
90+
*/
91+
getNextTurnEventId(turnId: string): Promise<string | undefined>;
92+
93+
/**
94+
* Returns the SDK event ID of the earliest turn in insertion order,
95+
* or `undefined` if there are no turns.
96+
*/
97+
getFirstTurnEventId(): Promise<string | undefined>;
98+
99+
/**
100+
* Deletes the given turn and all turns inserted after it, along
101+
* with their associated file edits (cascade).
102+
*/
103+
truncateFromTurn(turnId: string): Promise<void>;
104+
105+
/**
106+
* Deletes all turns inserted after the given turn (but keeps the
107+
* given turn itself). Associated file edits cascade-delete.
108+
*/
109+
deleteTurnsAfter(turnId: string): Promise<void>;
110+
111+
/**
112+
* Deletes all turns and their associated file edits.
113+
*/
114+
deleteAllTurns(): Promise<void>;
115+
71116
/**
72117
* Store a file-edit snapshot (metadata + content) for a tool invocation
73118
* within a turn.
@@ -122,6 +167,12 @@ export interface ISessionDatabase extends IDisposable {
122167
*/
123168
setMetadata(key: string, value: string): Promise<void>;
124169

170+
/**
171+
* Bulk-remaps turn IDs using the provided old→new mapping.
172+
* Used after copying a database file for a forked session.
173+
*/
174+
remapTurnIds(mapping: ReadonlyMap<string, string>): Promise<void>;
175+
125176
/**
126177
* Close the database connection. After calling this method, the object is
127178
* considered disposed and all other methods will reject with an error.

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

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,23 @@ export class AgentService extends Disposable implements IAgentService {
196196
throw new Error(`No agent provider registered for: ${providerId ?? '(none)'}`);
197197
}
198198

199+
// When forking, build the old→new turn ID mapping before creating the
200+
// session so the agent can use it to remap per-turn data.
201+
if (config?.fork) {
202+
const sourceState = this._stateManager.getSessionState(config.fork.session.toString());
203+
if (sourceState) {
204+
const sourceTurns = sourceState.turns.slice(0, config.fork.turnIndex + 1);
205+
const turnIdMapping = new Map<string, string>();
206+
for (const t of sourceTurns) {
207+
turnIdMapping.set(t.id, generateUuid());
208+
}
209+
config = {
210+
...config,
211+
fork: { ...config.fork, turnIdMapping },
212+
};
213+
}
214+
}
215+
199216
// Ensure the command auto-approver is ready before any session events
200217
// can arrive. This makes shell command auto-approval fully synchronous.
201218
// Safe to run in parallel with createSession since no events flow until
@@ -219,9 +236,9 @@ export class AgentService extends Disposable implements IAgentService {
219236
if (config?.fork) {
220237
const sourceState = this._stateManager.getSessionState(config.fork.session.toString());
221238
let sourceTurns: ITurn[] = [];
222-
if (sourceState) {
239+
if (sourceState && config.fork.turnIdMapping) {
223240
sourceTurns = sourceState.turns.slice(0, config.fork.turnIndex + 1)
224-
.map(t => ({ ...t, id: generateUuid() }));
241+
.map(t => ({ ...t, id: config!.fork!.turnIdMapping!.get(t.id) ?? generateUuid() }));
225242
}
226243

227244
const summary: ISessionSummary = {

src/vs/platform/agentHost/node/agentSideEffects.ts

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -626,17 +626,7 @@ export class AgentSideEffects extends Disposable {
626626
}
627627
case ActionType.SessionTruncated: {
628628
const agent = this._options.getAgent(action.session);
629-
let turnIndex: number | undefined;
630-
if (action.turnId !== undefined) {
631-
const state = this._stateManager.getSessionState(action.session);
632-
if (state) {
633-
const idx = state.turns.findIndex(t => t.id === action.turnId);
634-
if (idx >= 0) {
635-
turnIndex = idx;
636-
}
637-
}
638-
}
639-
agent?.truncateSession?.(URI.parse(action.session), turnIndex).catch(err => {
629+
agent?.truncateSession?.(URI.parse(action.session), action.turnId).catch(err => {
640630
this._logService.error('[AgentSideEffects] truncateSession failed', err);
641631
});
642632
// Turns were removed — recompute diffs from scratch (no changedTurnId)

src/vs/platform/agentHost/node/copilot/copilotAgent.ts

Lines changed: 57 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,11 @@ import { localize } from '../../../../nls.js';
2121
import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js';
2222
import { AgentHostSessionConfigBranchNameHintKey, AgentSession, IAgent, IAgentAttachment, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentMessageEvent, IAgentModelInfo, IAgentProgressEvent, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSessionProjectInfo, IAgentSubagentStartedEvent, IAgentToolCompleteEvent, IAgentToolStartEvent } from '../../common/agentService.js';
2323
import type { IResolveSessionConfigResult, ISessionConfigCompletionsResult } from '../../common/state/protocol/commands.js';
24-
import { ISessionDataService } from '../../common/sessionDataService.js';
24+
import { ISessionDataService, SESSION_DB_FILENAME } from '../../common/sessionDataService.js';
2525
import { CustomizationStatus, ICustomizationRef, SessionInputResponseKind, type ISessionInputAnswer, type IPendingMessage, type PolicyState } from '../../common/state/sessionState.js';
2626
import { CopilotAgentSession, SessionWrapperFactory } from './copilotAgentSession.js';
2727
import { parsedPluginsEqual, toSdkCustomAgents, toSdkHooks, toSdkMcpServers, toSdkSkillDirectories } from './copilotPluginConverters.js';
2828
import { CopilotSessionWrapper } from './copilotSessionWrapper.js';
29-
import { forkCopilotSessionOnDisk, getCopilotDataDir, truncateCopilotSessionOnDisk } from './copilotAgentForking.js';
3029
import { IProtectedResourceMetadata } from '../../common/state/protocol/state.js';
3130
import { IAgentHostGitService } from '../agentHostGitService.js';
3231
import { IAgentHostTerminalManager } from '../agentHostTerminalManager.js';
@@ -236,27 +235,50 @@ export class CopilotAgent extends Disposable implements IAgent {
236235
const client = await this._ensureClient();
237236
const parsedPlugins = await this._plugins.getAppliedPlugins();
238237

239-
// When forking, we manipulate the CLI's on-disk data and then resume
240-
// instead of creating a fresh session via the SDK.
238+
// When forking, use the SDK's sessions.fork RPC.
241239
if (config?.fork) {
242240
const sourceSessionId = AgentSession.id(config.fork.session);
243-
const newSessionId = config.session ? AgentSession.id(config.session) : generateUuid();
244241

245242
// Serialize against the source session to prevent concurrent
246-
// modifications while we read its on-disk data.
243+
// modifications while we read its state.
247244
return this._sessionSequencer.queue(sourceSessionId, async () => {
248-
this._logService.info(`[Copilot] Forking session ${sourceSessionId} at index ${config.fork!.turnIndex}${newSessionId}`);
245+
this._logService.info(`[Copilot] Forking session ${sourceSessionId} at turnId=${config.fork!.turnId}`);
249246

250-
// Ensure the source session is loaded so on-disk data is available
251-
if (!this._sessions.has(sourceSessionId)) {
252-
await this._resumeSession(sourceSessionId);
253-
}
247+
// Ensure the source session is loaded so we can read its event IDs
248+
const sourceEntry = this._sessions.get(sourceSessionId) ?? await this._resumeSession(sourceSessionId);
254249

255-
const copilotDataDir = getCopilotDataDir();
256-
await forkCopilotSessionOnDisk(copilotDataDir, sourceSessionId, newSessionId, config.fork!.turnIndex);
250+
// Look up the SDK event ID for the turn *after* the fork point.
251+
// toEventId is exclusive — events before it are included.
252+
// If there's no next turn, omit toEventId to include all events.
253+
const toEventId = await sourceEntry.getNextTurnEventId(config.fork!.turnId);
254+
255+
const forkResult = await client.rpc.sessions.fork({
256+
sessionId: sourceSessionId,
257+
...(toEventId ? { toEventId } : {}),
258+
});
259+
const newSessionId = forkResult.sessionId;
260+
261+
// Copy the source session's database file so the forked session
262+
// inherits turn event IDs and file-edit snapshots.
263+
const sourceDbDir = this._sessionDataService.getSessionDataDir(config.fork!.session);
264+
const targetDbDir = this._sessionDataService.getSessionDataDirById(newSessionId);
265+
const sourceDbPath = URI.joinPath(sourceDbDir, SESSION_DB_FILENAME);
266+
const targetDbPath = URI.joinPath(targetDbDir, SESSION_DB_FILENAME);
267+
try {
268+
await fs.mkdir(targetDbDir.fsPath, { recursive: true });
269+
await fs.copyFile(sourceDbPath.fsPath, targetDbPath.fsPath);
270+
} catch (err) {
271+
this._logService.warn(`[Copilot] Failed to copy session database for fork: ${err instanceof Error ? err.message : String(err)}`);
272+
}
257273

258274
// Resume the forked session so the SDK loads the forked history
259275
const agentSession = await this._resumeSession(newSessionId);
276+
277+
// Remap turn IDs to match the new protocol turn IDs
278+
if (config.fork!.turnIdMapping) {
279+
await agentSession.remapTurnIds(config.fork!.turnIdMapping);
280+
}
281+
260282
const session = agentSession.sessionUri;
261283
this._logService.info(`[Copilot] Forked session created: ${session.toString()}`);
262284
const project = await projectFromCopilotContext({ cwd: config.workingDirectory?.fsPath }, this._gitService);
@@ -448,39 +470,33 @@ export class CopilotAgent extends Disposable implements IAgent {
448470
});
449471
}
450472

451-
async truncateSession(session: URI, turnIndex?: number): Promise<void> {
473+
async truncateSession(session: URI, turnId?: string): Promise<void> {
452474
const sessionId = AgentSession.id(session);
453475
await this._sessionSequencer.queue(sessionId, async () => {
454-
this._logService.info(`[Copilot:${sessionId}] Truncating session${turnIndex !== undefined ? ` at index ${turnIndex}` : ' (all turns)'}`);
455-
456-
const keepUpToTurnIndex = turnIndex ?? -1;
457-
458-
// Destroy the SDK session first and wait for cleanup to complete,
459-
// ensuring on-disk data (events.jsonl, locks) is released before
460-
// we modify it. Then dispose the wrapper.
461-
const entry = this._sessions.get(sessionId);
462-
if (entry) {
463-
await entry.destroySession();
476+
this._logService.info(`[Copilot:${sessionId}] Truncating session${turnId !== undefined ? ` at turnId=${turnId}` : ' (all turns)'}`);
477+
478+
// Ensure the session is loaded so we can use the SDK RPC
479+
const entry = this._sessions.get(sessionId) ?? await this._resumeSession(sessionId);
480+
481+
// Look up the SDK event ID for the truncation boundary.
482+
// The protocol semantics: turnId is the last turn to KEEP.
483+
// The SDK semantics: eventId and all events after it are removed.
484+
// So we need the event ID of the *next* turn after turnId.
485+
// For "remove all", we need the first turn's event ID.
486+
let eventId: string | undefined;
487+
if (turnId) {
488+
eventId = await entry.getNextTurnEventId(turnId);
489+
} else {
490+
eventId = await entry.getFirstTurnEventId();
464491
}
465-
this._sessions.deleteAndDispose(sessionId);
466-
467-
const copilotDataDir = getCopilotDataDir();
468-
await truncateCopilotSessionOnDisk(copilotDataDir, sessionId, keepUpToTurnIndex);
469492

470-
// Resume the session from the modified on-disk data
471-
await this._resumeSession(sessionId);
472-
this._logService.info(`[Copilot:${sessionId}] Session truncated and resumed`);
473-
});
474-
}
475-
476-
async forkSession(sourceSession: URI, newSessionId: string, turnIndex: number): Promise<void> {
477-
const sourceSessionId = AgentSession.id(sourceSession);
478-
await this._sessionSequencer.queue(sourceSessionId, async () => {
479-
this._logService.info(`[Copilot] Forking session ${sourceSessionId} at index ${turnIndex}${newSessionId}`);
493+
if (eventId) {
494+
await entry.truncateAtEventId(eventId, turnId);
495+
} else {
496+
this._logService.info(`[Copilot:${sessionId}] No event ID found for truncation, nothing to truncate`);
497+
}
480498

481-
const copilotDataDir = getCopilotDataDir();
482-
await forkCopilotSessionOnDisk(copilotDataDir, sourceSessionId, newSessionId, turnIndex);
483-
this._logService.info(`[Copilot] Forked session ${newSessionId} created on disk`);
499+
this._logService.info(`[Copilot:${sessionId}] Session truncated`);
484500
});
485501
}
486502

0 commit comments

Comments
 (0)