Skip to content

Commit 9a3f8a4

Browse files
committed
fixs bugs, review, make it work
1 parent f7338de commit 9a3f8a4

10 files changed

Lines changed: 291 additions & 23 deletions

File tree

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,8 @@ export class AgentService extends Disposable implements IAgentService {
190190
const sourceState = this._stateManager.getSessionState(config.fork.session.toString());
191191
let sourceTurns: ITurn[] = [];
192192
if (sourceState) {
193-
sourceTurns = sourceState.turns.slice(0, config.fork.turnIndex + 1);
193+
sourceTurns = sourceState.turns.slice(0, config.fork.turnIndex + 1)
194+
.map(t => ({ ...t, id: generateUuid() }));
194195
}
195196

196197
const summary: ISessionSummary = {
@@ -202,7 +203,8 @@ export class AgentService extends Disposable implements IAgentService {
202203
modifiedAt: Date.now(),
203204
workingDirectory: config.workingDirectory?.toString(),
204205
};
205-
this._stateManager.restoreSession(summary, sourceTurns);
206+
const state = this._stateManager.createSession(summary);
207+
state.turns = sourceTurns;
206208
} else {
207209
// Create empty state for new sessions
208210
const summary: ISessionSummary = {

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

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -318,16 +318,15 @@ export class AgentSideEffects extends Disposable {
318318
}
319319
case ActionType.SessionTruncated: {
320320
const agent = this._options.getAgent(action.session);
321-
// Resolve the protocol turnId to a 0-based index using the
322-
// state manager's turn list. The reducer has already applied
323-
// the truncation, so we look at the pre-truncation state via
324-
// the turnId position.
325321
let turnIndex: number | undefined;
326322
if (action.turnId !== undefined) {
327323
const state = this._stateManager.getSessionState(action.session);
328-
// After the reducer, the turns array is already truncated.
329-
// The kept turns include the target, so its index = length - 1.
330-
turnIndex = state ? state.turns.length - 1 : undefined;
324+
if (state) {
325+
const idx = state.turns.findIndex(t => t.id === action.turnId);
326+
if (idx >= 0) {
327+
turnIndex = idx;
328+
}
329+
}
331330
}
332331
agent?.truncateSession?.(URI.parse(action.session), turnIndex).catch(err => {
333332
this._logService.error('[AgentSideEffects] truncateSession failed', err);

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

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -337,10 +337,8 @@ export class CopilotAgent extends Disposable implements IAgent {
337337
}
338338
this._sessions.deleteAndDispose(sessionId);
339339

340-
if (keepUpToTurnIndex >= 0) {
341-
const copilotDataDir = getCopilotDataDir();
342-
await truncateCopilotSessionOnDisk(copilotDataDir, sessionId, keepUpToTurnIndex);
343-
}
340+
const copilotDataDir = getCopilotDataDir();
341+
await truncateCopilotSessionOnDisk(copilotDataDir, sessionId, keepUpToTurnIndex);
344342

345343
// Resume the session from the modified on-disk data
346344
await this._resumeSession(sessionId);

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

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ export function buildForkedEventLog(
169169
}
170170

171171
const newParentId = entry.parentId !== null
172-
? (idMap.get(entry.parentId) ?? null)
172+
? (idMap.get(entry.parentId) ?? result[result.length - 1]?.id ?? null)
173173
: null;
174174

175175
result.push({
@@ -541,7 +541,24 @@ export async function truncateCopilotSessionOnDisk(
541541
const eventsPath = path.join(sessionDir, 'events.jsonl');
542542
const content = await fs.promises.readFile(eventsPath, 'utf-8');
543543
const entries = parseEventLog(content);
544-
const truncatedEntries = buildTruncatedEventLog(entries, keepUpToTurnIndex);
544+
545+
let truncatedEntries: ICopilotEventLogEntry[];
546+
if (keepUpToTurnIndex < 0) {
547+
// Truncate all turns: keep only a fresh session.start event
548+
const originalStart = entries.find(e => e.type === 'session.start');
549+
if (!originalStart) {
550+
throw new Error('No session.start event found in event log');
551+
}
552+
truncatedEntries = [{
553+
type: 'session.start',
554+
data: { ...originalStart.data, startTime: new Date().toISOString() },
555+
id: generateUuid(),
556+
timestamp: new Date().toISOString(),
557+
parentId: null,
558+
}];
559+
} else {
560+
truncatedEntries = buildTruncatedEventLog(entries, keepUpToTurnIndex);
561+
}
545562

546563
// Overwrite events.jsonl
547564
await fs.promises.writeFile(eventsPath, serializeEventLog(truncatedEntries), 'utf-8');

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -336,9 +336,12 @@ export class ProtocolServerHandler extends Disposable {
336336
let fork: { session: URI; turnIndex: number } | undefined;
337337
if (params.fork) {
338338
const sourceState = this._stateManager.getSessionState(params.fork.session);
339-
const turnIndex = sourceState?.turns.findIndex(t => t.id === params.fork!.turnId) ?? -1;
339+
if (!sourceState) {
340+
throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Fork source session not found: ${params.fork.session}`);
341+
}
342+
const turnIndex = sourceState.turns.findIndex(t => t.id === params.fork!.turnId);
340343
if (turnIndex < 0) {
341-
throw new ProtocolError(AHP_PROVIDER_NOT_FOUND, `Fork turn ID ${params.fork.turnId} not found in session ${params.fork.session}`);
344+
throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Fork turn ID ${params.fork.turnId} not found in session ${params.fork.session}`);
342345
}
343346
fork = { session: URI.parse(params.fork.session), turnIndex };
344347
}

src/vs/platform/agentHost/test/node/copilotAgentForking.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,30 @@ suite('CopilotAgentForking', () => {
246246
const entries = buildTestEventLog(1);
247247
assert.throws(() => buildForkedEventLog(entries, 5, 'new-session-id'));
248248
});
249+
250+
test('falls back to last kept event when lifecycle event parent is stripped', () => {
251+
const entries = buildTestEventLog(2);
252+
// Insert shutdown event between turns, then make the next turn's
253+
// user.message point to the shutdown event (which will be stripped)
254+
const shutdownEntry = makeEntry('session.shutdown', {
255+
id: 'shutdown-1',
256+
parentId: entries[4].id, // turn-end-0
257+
});
258+
entries.splice(5, 0, shutdownEntry);
259+
// Next entry (user-msg-1) now points to the shutdown event
260+
entries[6] = { ...entries[6], parentId: 'shutdown-1' };
261+
262+
const forked = buildForkedEventLog(entries, 1, 'new-session-id');
263+
264+
// All parentIds should be valid
265+
const idSet = new Set(forked.map(e => e.id));
266+
for (let i = 1; i < forked.length; i++) {
267+
assert.ok(
268+
forked[i].parentId !== null && idSet.has(forked[i].parentId!),
269+
`Event ${i} (${forked[i].type}) should have a valid parentId, got ${forked[i].parentId}`,
270+
);
271+
}
272+
});
249273
});
250274

251275
// ---- buildTruncatedEventLog -----------------------------------------
@@ -646,5 +670,36 @@ suite('CopilotAgentForking', () => {
646670
await close(db);
647671
}
648672
});
673+
674+
test('removes all turns when keepUpToTurnIndex is -1', async () => {
675+
const db = await openTestDb();
676+
try {
677+
await setupSchema(db);
678+
await exec(db, `
679+
INSERT INTO sessions (id, cwd, summary, created_at, updated_at)
680+
VALUES ('sess', '/test', 'Test', '2026-01-01', '2026-01-01');
681+
`);
682+
for (let i = 0; i < 3; i++) {
683+
await exec(db, `
684+
INSERT INTO turns (session_id, turn_index, user_message, timestamp)
685+
VALUES ('sess', ${i}, 'msg ${i}', '2026-01-01');
686+
`);
687+
await exec(db, `
688+
INSERT INTO search_index (content, session_id, source_type, source_id)
689+
VALUES ('content ${i}', 'sess', 'turn', 'sess:turn:${i}');
690+
`);
691+
}
692+
693+
await truncateSessionInDb(db, 'sess', -1);
694+
695+
const turns = await all(db, 'SELECT * FROM turns WHERE session_id = ?', ['sess']);
696+
assert.strictEqual(turns.length, 0, 'all turns should be removed');
697+
698+
const searchEntries = await all(db, 'SELECT * FROM search_index WHERE session_id = ?', ['sess']);
699+
assert.strictEqual(searchEntries.length, 0, 'all search entries should be removed');
700+
} finally {
701+
await close(db);
702+
}
703+
});
649704
});
650705
});

src/vs/platform/agentHost/test/node/mockAgent.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,16 @@ export class ScriptedMockAgent implements IAgent {
418418
// Mock agent doesn't track model state
419419
}
420420

421+
async truncateSession(_session: URI, _turnIndex?: number): Promise<void> {
422+
// Mock agent accepts truncation without side effects
423+
}
424+
425+
async forkSession(_sourceSession: URI, newSessionId: string, _turnIndex: number): Promise<void> {
426+
// Create the forked session so it can be resumed
427+
const session = AgentSession.uri('mock', newSessionId);
428+
this._sessions.set(newSessionId, session);
429+
}
430+
421431
respondToPermissionRequest(toolCallId: string, approved: boolean): void {
422432
const callback = this._pendingPermissions.get(toolCallId);
423433
if (callback) {

src/vs/platform/agentHost/test/node/protocolWebSocket.integrationTest.ts

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1270,4 +1270,175 @@ suite('Protocol WebSocket E2E', function () {
12701270

12711271
await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete'));
12721272
});
1273+
1274+
// ---- Truncation tests ---------------------------------------------------
1275+
1276+
test('truncate session removes turns after specified turn', async function () {
1277+
this.timeout(15_000);
1278+
1279+
const sessionUri = await createAndSubscribeSession(client, 'test-truncate');
1280+
1281+
// Create two turns
1282+
dispatchTurnStarted(client, sessionUri, 'turn-t1', 'hello', 1);
1283+
await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete') && (getActionEnvelope(n).action as { turnId: string }).turnId === 'turn-t1');
1284+
1285+
client.clearReceived();
1286+
dispatchTurnStarted(client, sessionUri, 'turn-t2', 'hello', 2);
1287+
await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete') && (getActionEnvelope(n).action as { turnId: string }).turnId === 'turn-t2');
1288+
1289+
// Verify 2 turns exist
1290+
let snapshot = await client.call<ISubscribeResult>('subscribe', { resource: sessionUri });
1291+
let state = snapshot.snapshot.state as ISessionState;
1292+
assert.strictEqual(state.turns.length, 2);
1293+
1294+
client.clearReceived();
1295+
1296+
// Truncate: keep only turn-t1
1297+
client.notify('dispatchAction', {
1298+
clientSeq: 3,
1299+
action: { type: 'session/truncated', session: sessionUri, turnId: 'turn-t1' },
1300+
});
1301+
1302+
await client.waitForNotification(n => isActionNotification(n, 'session/truncated'));
1303+
1304+
snapshot = await client.call<ISubscribeResult>('subscribe', { resource: sessionUri });
1305+
state = snapshot.snapshot.state as ISessionState;
1306+
assert.strictEqual(state.turns.length, 1);
1307+
assert.strictEqual(state.turns[0].id, 'turn-t1');
1308+
});
1309+
1310+
test('truncate all turns clears session history', async function () {
1311+
this.timeout(15_000);
1312+
1313+
const sessionUri = await createAndSubscribeSession(client, 'test-truncate-all');
1314+
1315+
dispatchTurnStarted(client, sessionUri, 'turn-ta1', 'hello', 1);
1316+
await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete'));
1317+
1318+
client.clearReceived();
1319+
1320+
// Truncate all (no turnId)
1321+
client.notify('dispatchAction', {
1322+
clientSeq: 2,
1323+
action: { type: 'session/truncated', session: sessionUri },
1324+
});
1325+
1326+
await client.waitForNotification(n => isActionNotification(n, 'session/truncated'));
1327+
1328+
const snapshot = await client.call<ISubscribeResult>('subscribe', { resource: sessionUri });
1329+
const state = snapshot.snapshot.state as ISessionState;
1330+
assert.strictEqual(state.turns.length, 0);
1331+
});
1332+
1333+
test('new turn after truncation works correctly', async function () {
1334+
this.timeout(15_000);
1335+
1336+
const sessionUri = await createAndSubscribeSession(client, 'test-truncate-resume');
1337+
1338+
dispatchTurnStarted(client, sessionUri, 'turn-tr1', 'hello', 1);
1339+
await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete') && (getActionEnvelope(n).action as { turnId: string }).turnId === 'turn-tr1');
1340+
1341+
client.clearReceived();
1342+
dispatchTurnStarted(client, sessionUri, 'turn-tr2', 'hello', 2);
1343+
await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete') && (getActionEnvelope(n).action as { turnId: string }).turnId === 'turn-tr2');
1344+
1345+
client.clearReceived();
1346+
1347+
// Truncate to turn-tr1
1348+
client.notify('dispatchAction', {
1349+
clientSeq: 3,
1350+
action: { type: 'session/truncated', session: sessionUri, turnId: 'turn-tr1' },
1351+
});
1352+
1353+
await client.waitForNotification(n => isActionNotification(n, 'session/truncated'));
1354+
1355+
// Send a new turn after truncation
1356+
dispatchTurnStarted(client, sessionUri, 'turn-tr3', 'hello', 4);
1357+
await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete'));
1358+
1359+
const snapshot = await client.call<ISubscribeResult>('subscribe', { resource: sessionUri });
1360+
const state = snapshot.snapshot.state as ISessionState;
1361+
assert.strictEqual(state.turns.length, 2);
1362+
assert.strictEqual(state.turns[0].id, 'turn-tr1');
1363+
assert.strictEqual(state.turns[1].id, 'turn-tr3');
1364+
});
1365+
1366+
// ---- Fork tests ---------------------------------------------------------
1367+
1368+
test('fork creates a new session with source history', async function () {
1369+
this.timeout(15_000);
1370+
1371+
const sessionUri = await createAndSubscribeSession(client, 'test-fork');
1372+
1373+
// Create two turns
1374+
dispatchTurnStarted(client, sessionUri, 'turn-f1', 'hello', 1);
1375+
await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete') && (getActionEnvelope(n).action as { turnId: string }).turnId === 'turn-f1');
1376+
1377+
client.clearReceived();
1378+
dispatchTurnStarted(client, sessionUri, 'turn-f2', 'hello', 2);
1379+
await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete') && (getActionEnvelope(n).action as { turnId: string }).turnId === 'turn-f2');
1380+
1381+
client.clearReceived();
1382+
1383+
// Fork at turn-f1 (keep turns up to and including turn-f1)
1384+
const forkedSessionUri = nextSessionUri();
1385+
await client.call('createSession', {
1386+
session: forkedSessionUri,
1387+
provider: 'mock',
1388+
fork: { session: sessionUri, turnId: 'turn-f1' },
1389+
});
1390+
1391+
const addedNotif = await client.waitForNotification(n =>
1392+
n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded'
1393+
);
1394+
const addedSession = (addedNotif.params as INotificationBroadcastParams).notification as ISessionAddedNotification;
1395+
1396+
// Subscribe — forked session should have 1 turn (from the protocol state
1397+
// populated during createSession with fork params).
1398+
const snapshot = await client.call<ISubscribeResult>('subscribe', { resource: addedSession.summary.resource });
1399+
const state = snapshot.snapshot.state as ISessionState;
1400+
assert.strictEqual(state.lifecycle, 'ready');
1401+
assert.strictEqual(state.turns.length, 1, 'forked session should have 1 turn');
1402+
1403+
// Source session should be unaffected
1404+
const sourceSnapshot = await client.call<ISubscribeResult>('subscribe', { resource: sessionUri });
1405+
const sourceState = sourceSnapshot.snapshot.state as ISessionState;
1406+
assert.strictEqual(sourceState.turns.length, 2);
1407+
});
1408+
1409+
test('fork with invalid turn ID returns error', async function () {
1410+
this.timeout(10_000);
1411+
1412+
const sessionUri = await createAndSubscribeSession(client, 'test-fork-invalid');
1413+
1414+
let gotError = false;
1415+
try {
1416+
await client.call('createSession', {
1417+
session: nextSessionUri(),
1418+
provider: 'mock',
1419+
fork: { session: sessionUri, turnId: 'nonexistent-turn' },
1420+
});
1421+
} catch {
1422+
gotError = true;
1423+
}
1424+
assert.ok(gotError, 'should get error for invalid fork turn ID');
1425+
});
1426+
1427+
test('fork with invalid source session returns error', async function () {
1428+
this.timeout(10_000);
1429+
1430+
await client.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-fork-no-source' });
1431+
1432+
let gotError = false;
1433+
try {
1434+
await client.call('createSession', {
1435+
session: nextSessionUri(),
1436+
provider: 'mock',
1437+
fork: { session: 'mock://nonexistent-session', turnId: 'turn-1' },
1438+
});
1439+
} catch {
1440+
gotError = true;
1441+
}
1442+
assert.ok(gotError, 'should get error for invalid fork source session');
1443+
});
12731444
});

0 commit comments

Comments
 (0)