Skip to content

Commit 98f6cbe

Browse files
authored
Merge pull request #306968 from microsoft/connor4312/ah-undo-redo
agentHost: support checkpointing and forking
2 parents 6b19c23 + 9a3f8a4 commit 98f6cbe

26 files changed

Lines changed: 2321 additions & 73 deletions

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,8 @@ export interface IAgentCreateSessionConfig {
102102
readonly model?: string;
103103
readonly session?: URI;
104104
readonly workingDirectory?: URI;
105+
/** Fork from an existing session at a specific turn index. */
106+
readonly fork?: { readonly session: URI; readonly turnIndex: number };
105107
}
106108

107109
/** Serializable attachment passed alongside a message to the agent host. */
@@ -365,6 +367,23 @@ export interface IAgent {
365367
*/
366368
authenticate(resource: string, token: string): Promise<boolean>;
367369

370+
/**
371+
* Truncate a session's history. If `turnIndex` is provided (0-based), keeps
372+
* turns up to and including that turn. If omitted, all turns are removed.
373+
* Optional — not all providers support truncation.
374+
*/
375+
truncateSession?(session: URI, turnIndex?: number): Promise<void>;
376+
377+
/**
378+
* Fork a session at a specific turn, creating a new session on disk
379+
* with the source session's history up to and including the specified turn.
380+
* Optional — not all providers support forking.
381+
*
382+
* @param turnIndex 0-based turn index to fork at.
383+
* @returns The new session's raw ID.
384+
*/
385+
forkSession?(sourceSession: URI, newSessionId: string, turnIndex: number): Promise<void>;
386+
368387
/**
369388
* Receives client-provided customization refs and syncs them (e.g. copies
370389
* plugin files to local storage). Returns per-customization status with

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

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

12-
import { ActionType, type IStateAction, type IRootAgentsChangedAction, type IRootActiveSessionsChangedAction, type ISessionReadyAction, type ISessionCreationFailedAction, type ISessionTurnStartedAction, type ISessionDeltaAction, type ISessionResponsePartAction, type ISessionToolCallStartAction, type ISessionToolCallDeltaAction, type ISessionToolCallReadyAction, type ISessionToolCallConfirmedAction, type ISessionToolCallCompleteAction, type ISessionToolCallResultConfirmedAction, type ISessionTurnCompleteAction, type ISessionTurnCancelledAction, type ISessionErrorAction, type ISessionTitleChangedAction, type ISessionUsageAction, type ISessionReasoningAction, type ISessionModelChangedAction, type ISessionServerToolsChangedAction, type ISessionActiveClientChangedAction, type ISessionActiveClientToolsChangedAction, type ISessionPendingMessageSetAction, type ISessionPendingMessageRemovedAction, type ISessionQueuedMessagesReorderedAction, type ISessionCustomizationsChangedAction, type ISessionCustomizationToggledAction } from './actions.js';
12+
import { ActionType, type IStateAction, type IRootAgentsChangedAction, type IRootActiveSessionsChangedAction, type ISessionReadyAction, type ISessionCreationFailedAction, type ISessionTurnStartedAction, type ISessionDeltaAction, type ISessionResponsePartAction, type ISessionToolCallStartAction, type ISessionToolCallDeltaAction, type ISessionToolCallReadyAction, type ISessionToolCallConfirmedAction, type ISessionToolCallCompleteAction, type ISessionToolCallResultConfirmedAction, type ISessionTurnCompleteAction, type ISessionTurnCancelledAction, type ISessionErrorAction, type ISessionTitleChangedAction, type ISessionUsageAction, type ISessionReasoningAction, type ISessionModelChangedAction, type ISessionServerToolsChangedAction, type ISessionActiveClientChangedAction, type ISessionActiveClientToolsChangedAction, type ISessionPendingMessageSetAction, type ISessionPendingMessageRemovedAction, type ISessionQueuedMessagesReorderedAction, type ISessionCustomizationsChangedAction, type ISessionCustomizationToggledAction, type ISessionTruncatedAction } from './actions.js';
1313

1414

1515
// ─── Root vs Session Action Unions ───────────────────────────────────────────
@@ -48,6 +48,7 @@ export type ISessionAction =
4848
| ISessionQueuedMessagesReorderedAction
4949
| ISessionCustomizationsChangedAction
5050
| ISessionCustomizationToggledAction
51+
| ISessionTruncatedAction
5152
;
5253

5354
/** Union of session actions that clients may dispatch. */
@@ -65,6 +66,7 @@ export type IClientSessionAction =
6566
| ISessionPendingMessageRemovedAction
6667
| ISessionQueuedMessagesReorderedAction
6768
| ISessionCustomizationToggledAction
69+
| ISessionTruncatedAction
6870
;
6971

7072
/** Union of session actions that only the server may produce. */
@@ -119,4 +121,5 @@ export const IS_CLIENT_DISPATCHABLE: { readonly [K in IStateAction['type']]: boo
119121
[ActionType.SessionQueuedMessagesReordered]: true,
120122
[ActionType.SessionCustomizationsChanged]: false,
121123
[ActionType.SessionCustomizationToggled]: true,
124+
[ActionType.SessionTruncated]: true,
122125
};

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

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export const enum ActionType {
4545
SessionQueuedMessagesReordered = 'session/queuedMessagesReordered',
4646
SessionCustomizationsChanged = 'session/customizationsChanged',
4747
SessionCustomizationToggled = 'session/customizationToggled',
48+
SessionTruncated = 'session/truncated',
4849
}
4950

5051
// ─── Action Envelope ─────────────────────────────────────────────────────────
@@ -562,6 +563,31 @@ export interface ISessionCustomizationToggledAction {
562563
enabled: boolean;
563564
}
564565

566+
// ─── Truncation ──────────────────────────────────────────────────────────────
567+
568+
/**
569+
* Truncates a session's history. If `turnId` is provided, all turns after that
570+
* turn are removed and the specified turn is kept. If `turnId` is omitted, all
571+
* turns are removed.
572+
*
573+
* If there is an active turn it is silently dropped and the session status
574+
* returns to `idle`.
575+
*
576+
* Common use-case: truncate old data then dispatch a new
577+
* `session/turnStarted` with an edited message.
578+
*
579+
* @category Session Actions
580+
* @version 1
581+
* @clientDispatchable
582+
*/
583+
export interface ISessionTruncatedAction {
584+
type: ActionType.SessionTruncated;
585+
/** Session URI */
586+
session: URI;
587+
/** Keep turns up to and including this turn. Omit to clear all turns. */
588+
turnId?: string;
589+
}
590+
565591
// ─── Pending Message Actions ─────────────────────────────────────────────────
566592

567593
/**
@@ -664,4 +690,5 @@ export type IStateAction =
664690
| ISessionPendingMessageRemovedAction
665691
| ISessionQueuedMessagesReorderedAction
666692
| ISessionCustomizationsChangedAction
667-
| ISessionCustomizationToggledAction;
693+
| ISessionCustomizationToggledAction
694+
| ISessionTruncatedAction;

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,20 @@ export interface ISubscribeResult {
163163
* { "jsonrpc": "2.0", "id": 2, "error": { "code": -32003, "message": "Session already exists" } }
164164
* ```
165165
*/
166+
/**
167+
* Identifies a source session and turn to fork from.
168+
*
169+
* When provided in `createSession`, the server populates the new session with
170+
* content from the source session up to and including the response of the
171+
* specified turn.
172+
*/
173+
export interface ISessionForkSource {
174+
/** URI of the existing session to fork from */
175+
session: URI;
176+
/** Turn ID in the source session; content up to and including this turn's response is copied */
177+
turnId: string;
178+
}
179+
166180
export interface ICreateSessionParams {
167181
/** Session URI (client-chosen, e.g. `copilot:/<uuid>`) */
168182
session: URI;
@@ -172,6 +186,11 @@ export interface ICreateSessionParams {
172186
model?: string;
173187
/** Working directory for the session */
174188
workingDirectory?: URI;
189+
/**
190+
* Fork from an existing session. The new session is populated with content
191+
* from the source session up to and including the specified turn's response.
192+
*/
193+
fork?: ISessionForkSource;
175194
}
176195

177196
// ─── disposeSession ──────────────────────────────────────────────────────────

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,27 @@ export function sessionReducer(state: ISessionState, action: ISessionAction, log
485485
return { ...state, customizations: updated };
486486
}
487487

488+
// ── Truncation ────────────────────────────────────────────────────────
489+
490+
case ActionType.SessionTruncated: {
491+
let turns: typeof state.turns;
492+
if (action.turnId === undefined) {
493+
turns = [];
494+
} else {
495+
const idx = state.turns.findIndex(t => t.id === action.turnId);
496+
if (idx < 0) {
497+
return state;
498+
}
499+
turns = state.turns.slice(0, idx + 1);
500+
}
501+
return {
502+
...state,
503+
turns,
504+
activeTurn: undefined,
505+
summary: { ...state.summary, status: SessionStatus.Idle, modifiedAt: Date.now() },
506+
};
507+
}
508+
488509
// ── Pending Messages ──────────────────────────────────────────────────
489510

490511
case ActionType.SessionPendingMessageSet: {

src/vs/platform/agentHost/common/state/protocol/version/registry.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export const ACTION_INTRODUCED_IN: { readonly [K in IStateAction['type']]: numbe
5252
[ActionType.SessionQueuedMessagesReordered]: 1,
5353
[ActionType.SessionCustomizationsChanged]: 1,
5454
[ActionType.SessionCustomizationToggled]: 1,
55+
[ActionType.SessionTruncated]: 1,
5556
};
5657

5758
/**

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

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -184,17 +184,40 @@ export class AgentService extends Disposable implements IAgentService {
184184
this._sessionToProvider.set(session.toString(), provider.id);
185185
this._logService.trace(`[AgentService] createSession returned: ${session.toString()}`);
186186

187-
// Create state in the state manager
188-
const summary: ISessionSummary = {
189-
resource: session.toString(),
190-
provider: provider.id,
191-
title: 'New Session',
192-
status: SessionStatus.Idle,
193-
createdAt: Date.now(),
194-
modifiedAt: Date.now(),
195-
workingDirectory: config?.workingDirectory?.toString(),
196-
};
197-
this._stateManager.createSession(summary);
187+
// When forking, populate the new session's protocol state with
188+
// the source session's turns so the client sees the forked history.
189+
if (config?.fork) {
190+
const sourceState = this._stateManager.getSessionState(config.fork.session.toString());
191+
let sourceTurns: ITurn[] = [];
192+
if (sourceState) {
193+
sourceTurns = sourceState.turns.slice(0, config.fork.turnIndex + 1)
194+
.map(t => ({ ...t, id: generateUuid() }));
195+
}
196+
197+
const summary: ISessionSummary = {
198+
resource: session.toString(),
199+
provider: provider.id,
200+
title: sourceState?.summary.title ?? 'Forked Session',
201+
status: SessionStatus.Idle,
202+
createdAt: Date.now(),
203+
modifiedAt: Date.now(),
204+
workingDirectory: config.workingDirectory?.toString(),
205+
};
206+
const state = this._stateManager.createSession(summary);
207+
state.turns = sourceTurns;
208+
} else {
209+
// Create empty state for new sessions
210+
const summary: ISessionSummary = {
211+
resource: session.toString(),
212+
provider: provider.id,
213+
title: 'New Session',
214+
status: SessionStatus.Idle,
215+
createdAt: Date.now(),
216+
modifiedAt: Date.now(),
217+
workingDirectory: config?.workingDirectory?.toString(),
218+
};
219+
this._stateManager.createSession(summary);
220+
}
198221
this._stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: session.toString() });
199222

200223
return session;

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,23 @@ export class AgentSideEffects extends Disposable {
316316
this._syncPendingMessages(action.session);
317317
break;
318318
}
319+
case ActionType.SessionTruncated: {
320+
const agent = this._options.getAgent(action.session);
321+
let turnIndex: number | undefined;
322+
if (action.turnId !== undefined) {
323+
const state = this._stateManager.getSessionState(action.session);
324+
if (state) {
325+
const idx = state.turns.findIndex(t => t.id === action.turnId);
326+
if (idx >= 0) {
327+
turnIndex = idx;
328+
}
329+
}
330+
}
331+
agent?.truncateSession?.(URI.parse(action.session), turnIndex).catch(err => {
332+
this._logService.error('[AgentSideEffects] truncateSession failed', err);
333+
});
334+
break;
335+
}
319336
case ActionType.SessionActiveClientChanged: {
320337
const agent = this._options.getAgent(action.session);
321338
const refs = action.activeClient?.customizations;

0 commit comments

Comments
 (0)