agentHost: add session-specific metadata

Adds a SQLite DB for session-specific metadata. Stores edits in there.
It can _almost_ restore edits, but I still need to make undoStops be
similarly persisted. That is a project for later this evening.
This commit is contained in:
Connor Peet
2026-03-26 15:46:46 -07:00
parent 29d9808be8
commit da5fb11b3b
19 changed files with 1688 additions and 177 deletions

View File

@@ -80,8 +80,12 @@ export class AgentHostFileSystemProvider extends Disposable implements IFileSyst
if (path === '/' || path === '') {
return { type: FileType.Directory, mtime: 0, ctime: 0, size: 0, permissions: FilePermission.Readonly };
}
const decodedPath = fromAgentHostUri(resource).path;
if (decodedPath === '/' || decodedPath === '') {
const decoded = fromAgentHostUri(resource);
if (decoded.scheme === 'session-db') {
return { type: FileType.File, mtime: 0, ctime: 0, size: 0, permissions: FilePermission.Readonly };
}
if (decoded.path === '/' || decoded.path === '') {
return { type: FileType.Directory, mtime: 0, ctime: 0, size: 0, permissions: FilePermission.Readonly };
}

View File

@@ -4,6 +4,7 @@
*--------------------------------------------------------------------------------------------*/
import { encodeBase64, VSBuffer } from '../../../base/common/buffer.js';
import { Schemas } from '../../../base/common/network.js';
import { URI } from '../../../base/common/uri.js';
import type { ResourceLabelFormatter } from '../../label/common/label.js';
@@ -34,7 +35,7 @@ export const AGENT_HOST_SCHEME = 'vscode-agent-host';
* the URI authority (from {@link agentHostAuthority}).
*/
export function toAgentHostUri(originalUri: URI, connectionAuthority: string): URI {
if (connectionAuthority === 'local') {
if (connectionAuthority === 'local' && originalUri.scheme === Schemas.file) {
return originalUri;
}

View File

@@ -3,11 +3,86 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { IDisposable, IReference } from '../../../base/common/lifecycle.js';
import { URI } from '../../../base/common/uri.js';
import { createDecorator } from '../../instantiation/common/instantiation.js';
export const ISessionDataService = createDecorator<ISessionDataService>('sessionDataService');
// ---- File-edit types ----------------------------------------------------
/**
* Lightweight metadata for a file edit. Returned by {@link ISessionDatabase.getFileEdits}
* without the (potentially large) file content blobs.
*/
export interface IFileEditRecord {
/** The turn that owns this file edit. */
turnId: string;
/** The tool call that produced this edit. */
toolCallId: string;
/** Absolute file path that was edited. */
filePath: string;
/** Number of lines added (informational, for diff metadata). */
addedLines: number | undefined;
/** Number of lines removed (informational, for diff metadata). */
removedLines: number | undefined;
}
/**
* The before/after content blobs for a single file edit.
* Retrieved on demand via {@link ISessionDatabase.readFileEditContent}.
*/
export interface IFileEditContent {
/** File content before the edit (may be empty for newly created files). */
beforeContent: Uint8Array;
/** File content after the edit. */
afterContent: Uint8Array;
}
// ---- Session database ---------------------------------------------------
/**
* A disposable handle to a per-session SQLite database backed by
* `@vscode/sqlite3`.
*
* Callers obtain an instance via {@link ISessionDataService.openDatabase} and
* **must** dispose it when finished to close the underlying database connection.
*/
export interface ISessionDatabase extends IDisposable {
/**
* Create a turn record. Must be called before storing file edits that
* reference this turn.
*/
createTurn(turnId: string): Promise<void>;
/**
* Delete a turn and all of its associated file edits (cascade).
*/
deleteTurn(turnId: string): Promise<void>;
/**
* Store a file-edit snapshot (metadata + content) for a tool invocation
* within a turn.
*
* If a record for the same `toolCallId` and `filePath` already exists
* it is replaced.
*/
storeFileEdit(edit: IFileEditRecord & IFileEditContent): Promise<void>;
/**
* Retrieve file-edit metadata for the given tool call IDs.
* Content blobs are **not** included — use {@link readFileEditContent}
* to fetch them on demand. Results are returned in insertion order.
*/
getFileEdits(toolCallIds: string[]): Promise<IFileEditRecord[]>;
/**
* Read the before/after content blobs for a single file edit.
* Returns `undefined` if no edit exists for the given key.
*/
readFileEditContent(toolCallId: string, filePath: string): Promise<IFileEditContent | undefined>;
}
/**
* Provides persistent, per-session data directories on disk.
*
@@ -34,6 +109,17 @@ export interface ISessionDataService {
*/
getSessionDataDirById(sessionId: string): URI;
/**
* Opens (or creates) a per-session SQLite database. The database file is
* stored at `{sessionDataDir}/session.db`. Migrations are applied
* automatically on first use.
*
* Returns a ref-counted reference. Multiple callers for the same session
* share the same underlying connection. The connection is closed when
* the last reference is disposed.
*/
openDatabase(session: URI): IReference<ISessionDatabase>;
/**
* Recursively deletes the data directory for a session, if it exists.
*/

View File

@@ -171,7 +171,7 @@ export class AgentService extends Disposable implements IAgentService {
await provider.disposeSession(session);
this._sessionToProvider.delete(session.toString());
}
this._stateManager.removeSession(session.toString());
this._stateManager.deleteSession(session.toString());
this._sessionDataService.deleteSessionData(session);
}
@@ -179,7 +179,11 @@ export class AgentService extends Disposable implements IAgentService {
async subscribe(resource: URI): Promise<IStateSnapshot> {
this._logService.trace(`[AgentService] subscribe: ${resource.toString()}`);
const snapshot = this._stateManager.getSnapshot(resource.toString());
let snapshot = this._stateManager.getSnapshot(resource.toString());
if (!snapshot) {
await this.restoreSession(resource);
snapshot = this._stateManager.getSnapshot(resource.toString());
}
if (!snapshot) {
throw new Error(`Cannot subscribe to unknown resource: ${resource.toString()}`);
}

View File

@@ -29,6 +29,7 @@ import {
type URI as ProtocolURI,
} from '../common/state/sessionState.js';
import { AgentEventMapper } from './agentEventMapper.js';
import { ISessionDbUriFields, parseSessionDbUri } from './copilot/fileEditTracker.js';
import type { IProtocolSideEffectHandler } from './protocolServerHandler.js';
import { SessionStateManager } from './sessionStateManager.js';
@@ -339,7 +340,7 @@ export class AgentSideEffects extends Disposable implements IProtocolSideEffectH
handleDisposeSession(session: ProtocolURI): void {
const agent = this._options.getAgent(session);
agent?.disposeSession(URI.parse(session)).catch(() => { });
this._stateManager.removeSession(session);
this._stateManager.deleteSession(session);
this._options.sessionDataService.deleteSessionData(URI.parse(session));
}
@@ -567,6 +568,13 @@ export class AgentSideEffects extends Disposable implements IProtocolSideEffectH
}
async handleFetchContent(uri: ProtocolURI): Promise<IFetchContentResult> {
// Handle session-db: URIs that reference file-edit content stored
// in a per-session SQLite database.
const dbFields = parseSessionDbUri(uri);
if (dbFields) {
return this._fetchSessionDbContent(dbFields);
}
try {
const content = await this._fileService.readFile(URI.parse(uri));
return {
@@ -579,6 +587,25 @@ export class AgentSideEffects extends Disposable implements IProtocolSideEffectH
}
}
private async _fetchSessionDbContent(fields: ISessionDbUriFields): Promise<IFetchContentResult> {
const sessionUri = URI.parse(fields.sessionUri);
const ref = this._options.sessionDataService.openDatabase(sessionUri);
try {
const content = await ref.object.readFileEditContent(fields.toolCallId, fields.filePath);
if (!content) {
throw new ProtocolError(AhpErrorCodes.NotFound, `File edit not found: toolCallId=${fields.toolCallId}, filePath=${fields.filePath}`);
}
const bytes = fields.part === 'before' ? content.beforeContent : content.afterContent;
return {
data: new TextDecoder().decode(bytes),
encoding: ContentEncoding.Utf8,
contentType: 'text/plain',
};
} finally {
ref.dispose();
}
}
override dispose(): void {
this._toolCallAgents.clear();
super.dispose();

View File

@@ -3,11 +3,11 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { CopilotClient, CopilotSession, type SessionEvent, type SessionEventPayload } from '@github/copilot-sdk';
import { CopilotClient, CopilotSession } from '@github/copilot-sdk';
import { rgPath } from '@vscode/ripgrep';
import { DeferredPromise } from '../../../../base/common/async.js';
import { Emitter } from '../../../../base/common/event.js';
import { Disposable, DisposableMap } from '../../../../base/common/lifecycle.js';
import { Disposable, DisposableMap, IReference } from '../../../../base/common/lifecycle.js';
import { FileAccess } from '../../../../base/common/network.js';
import type { IAuthorizationProtectedResourceMetadata } from '../../../../base/common/oauth.js';
import { delimiter, dirname } from '../../../../base/common/path.js';
@@ -16,11 +16,12 @@ import { IFileService } from '../../../files/common/files.js';
import { ILogService } from '../../../log/common/log.js';
import { localize } from '../../../../nls.js';
import { AgentSession, IAgent, IAgentAttachment, IAgentCreateSessionConfig, IAgentDescriptor, IAgentMessageEvent, IAgentModelInfo, IAgentProgressEvent, IAgentSessionMetadata, IAgentToolCompleteEvent, IAgentToolStartEvent } from '../../common/agentService.js';
import { ISessionDataService } from '../../common/sessionDataService.js';
import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js';
import { ToolResultContentType, type IPendingMessage, type IToolResultContent, type PolicyState } from '../../common/state/sessionState.js';
import { CopilotSessionWrapper } from './copilotSessionWrapper.js';
import { getEditFilePath, getInvocationMessage, getPastTenseMessage, getShellLanguage, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool } from './copilotToolDisplay.js';
import { FileEditTracker } from './fileEditTracker.js';
import { mapSessionEvents } from './mapSessionEvents.js';
function tryStringify(value: unknown): string | undefined {
try {
@@ -51,6 +52,8 @@ export class CopilotAgent extends Disposable implements IAgent {
private readonly _sessionWorkingDirs = new Map<string, string>();
/** File edit trackers per session, keyed by raw session ID. */
private readonly _editTrackers = new Map<string, FileEditTracker>();
/** Session database references, keyed by raw session ID. */
private readonly _sessionDatabases = this._register(new DisposableMap<string, IReference<ISessionDatabase>>());
constructor(
@ILogService private readonly _logService: ILogService,
@@ -270,7 +273,13 @@ export class CopilotAgent extends Disposable implements IAgent {
}
const events = await entry.session.getMessages();
return this._mapSessionEvents(session, events);
let db: ISessionDatabase | undefined;
try {
db = this._getSessionDatabase(sessionId);
} catch {
// Database may not exist yet — that's fine
}
return mapSessionEvents(session, db, events);
}
async disposeSession(session: URI): Promise<void> {
@@ -278,6 +287,7 @@ export class CopilotAgent extends Disposable implements IAgent {
this._sessions.deleteAndDispose(sessionId);
this._clearToolCallsForSession(sessionId);
this._sessionWorkingDirs.delete(sessionId);
this._sessionDatabases.deleteAndDispose(sessionId);
this._denyPendingPermissionsForSession(sessionId);
}
@@ -306,6 +316,7 @@ export class CopilotAgent extends Disposable implements IAgent {
this._activeToolCalls.clear();
this._sessionWorkingDirs.clear();
this._denyPendingPermissions();
this._sessionDatabases.clearAndDisposeAll();
await this._client?.stop();
this._client = undefined;
}
@@ -439,10 +450,22 @@ export class CopilotAgent extends Disposable implements IAgent {
}
}
private _getSessionDatabase(rawSessionId: string): ISessionDatabase {
let ref = this._sessionDatabases.get(rawSessionId);
if (!ref) {
const session = AgentSession.uri(this.id, rawSessionId);
ref = this._sessionDataService.openDatabase(session);
this._sessionDatabases.set(rawSessionId, ref);
}
return ref.object;
}
private _getOrCreateEditTracker(rawSessionId: string): FileEditTracker {
let tracker = this._editTrackers.get(rawSessionId);
if (!tracker) {
tracker = new FileEditTracker(rawSessionId, this._sessionDataService, this._fileService, this._logService);
const session = AgentSession.uri(this.id, rawSessionId);
const db = this._getSessionDatabase(rawSessionId);
tracker = new FileEditTracker(session.toString(), db, this._fileService, this._logService);
this._editTrackers.set(rawSessionId, tracker);
}
return tracker;
@@ -547,6 +570,11 @@ export class CopilotAgent extends Disposable implements IAgent {
});
});
let turnId: string = '';
wrapper.onTurnStart(e => {
turnId = e.data.turnId;
});
wrapper.onToolComplete(e => {
const trackingKey = `${rawId}:${e.data.toolCallId}`;
const tracked = this._activeToolCalls.get(trackingKey);
@@ -567,7 +595,7 @@ export class CopilotAgent extends Disposable implements IAgent {
const tracker = this._editTrackers.get(rawId);
const filePath = isEditTool(tracked.toolName) ? getEditFilePath(tracked.parameters) : undefined;
if (tracker && filePath) {
const fileEdit = tracker.takeCompletedEdit(filePath);
const fileEdit = tracker.takeCompletedEdit(turnId, e.data.toolCallId, filePath);
if (fileEdit) {
content.push(fileEdit);
}
@@ -761,89 +789,6 @@ export class CopilotAgent extends Disposable implements IAgent {
return this._trackSession(raw, sessionId);
}
private _mapSessionEvents(session: URI, events: readonly SessionEvent[]): (IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent)[] {
const result: (IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent)[] = [];
const toolInfoByCallId = new Map<string, { toolName: string; parameters: Record<string, unknown> | undefined }>();
for (const e of events) {
if (e.type === 'assistant.message' || e.type === 'user.message') {
const d = (e as SessionEventPayload<'assistant.message'>).data;
result.push({
session,
type: 'message',
role: e.type === 'user.message' ? 'user' : 'assistant',
messageId: d?.messageId ?? '',
content: d?.content ?? '',
toolRequests: d?.toolRequests?.map((tr: { toolCallId: string; name: string; arguments?: unknown; type?: 'function' | 'custom' }) => ({
toolCallId: tr.toolCallId,
name: tr.name,
arguments: tr.arguments !== undefined ? tryStringify(tr.arguments) : undefined,
type: tr.type,
})),
reasoningOpaque: d?.reasoningOpaque,
reasoningText: d?.reasoningText,
encryptedContent: d?.encryptedContent,
parentToolCallId: d?.parentToolCallId,
});
} else if (e.type === 'tool.execution_start') {
const d = (e as SessionEventPayload<'tool.execution_start'>).data;
if (isHiddenTool(d.toolName)) {
continue;
}
const toolArgs = d.arguments !== undefined ? tryStringify(d.arguments) : undefined;
let parameters: Record<string, unknown> | undefined;
if (toolArgs) {
try { parameters = JSON.parse(toolArgs) as Record<string, unknown>; } catch { /* ignore */ }
}
toolInfoByCallId.set(d.toolCallId, { toolName: d.toolName, parameters });
const displayName = getToolDisplayName(d.toolName);
const toolKind = getToolKind(d.toolName);
result.push({
session,
type: 'tool_start',
toolCallId: d.toolCallId,
toolName: d.toolName,
displayName,
invocationMessage: getInvocationMessage(d.toolName, displayName, parameters),
toolInput: getToolInputString(d.toolName, parameters, toolArgs),
toolKind,
language: toolKind === 'terminal' ? getShellLanguage(d.toolName) : undefined,
toolArguments: toolArgs,
mcpServerName: d.mcpServerName,
mcpToolName: d.mcpToolName,
parentToolCallId: d.parentToolCallId,
});
} else if (e.type === 'tool.execution_complete') {
const d = (e as SessionEventPayload<'tool.execution_complete'>).data;
const info = toolInfoByCallId.get(d.toolCallId);
if (!info) {
continue;
}
toolInfoByCallId.delete(d.toolCallId);
const displayName = getToolDisplayName(info.toolName);
const toolOutput = d.error?.message ?? d.result?.content;
const content: IToolResultContent[] = [];
if (toolOutput !== undefined) {
content.push({ type: ToolResultContentType.Text, text: toolOutput });
}
result.push({
session,
type: 'tool_complete',
toolCallId: d.toolCallId,
result: {
success: d.success,
pastTenseMessage: getPastTenseMessage(info.toolName, displayName, info.parameters, d.success),
content: content.length > 0 ? content : undefined,
error: d.error,
},
isUserRequested: d.isUserRequested,
toolTelemetry: d.toolTelemetry !== undefined ? tryStringify(d.toolTelemetry) : undefined,
});
}
}
return result;
}
override dispose(): void {
this._denyPendingPermissions();
this._client?.stop().catch(() => { /* best-effort */ });

View File

@@ -3,16 +3,60 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { VSBuffer } from '../../../../base/common/buffer.js';
import { decodeHex, encodeHex, VSBuffer } from '../../../../base/common/buffer.js';
import { URI } from '../../../../base/common/uri.js';
import { IFileService } from '../../../files/common/files.js';
import { ILogService } from '../../../log/common/log.js';
import { ISessionDataService } from '../../common/sessionDataService.js';
import { ISessionDatabase } from '../../common/sessionDataService.js';
import { ToolResultContentType, type IToolResultFileEditContent } from '../../common/state/sessionState.js';
const SESSION_DB_SCHEME = 'session-db';
/**
* Builds a `session-db:` URI that references a file-edit content blob
* stored in the session database. Parsed by {@link parseSessionDbUri}.
*/
export function buildSessionDbUri(sessionUri: string, toolCallId: string, filePath: string, part: 'before' | 'after'): string {
return URI.from({
scheme: SESSION_DB_SCHEME,
authority: encodeHex(VSBuffer.fromString(sessionUri)).toString(),
path: `/${encodeURIComponent(toolCallId)}/${encodeHex(VSBuffer.fromString(filePath))}/${part}`,
}).toString();
}
/** Parsed fields from a `session-db:` content URI. */
export interface ISessionDbUriFields {
sessionUri: string;
toolCallId: string;
filePath: string;
part: 'before' | 'after';
}
/**
* Parses a `session-db:` URI produced by {@link buildSessionDbUri}.
* Returns `undefined` if the URI is not a valid `session-db:` URI.
*/
export function parseSessionDbUri(raw: string): ISessionDbUriFields | undefined {
const parsed = URI.parse(raw);
if (parsed.scheme !== SESSION_DB_SCHEME) {
return undefined;
}
const [, toolCallId, filePath, part] = parsed.path.split('/');
if (!toolCallId || !filePath || (part !== 'before' && part !== 'after')) {
return undefined;
}
return {
sessionUri: decodeHex(parsed.authority).toString(),
toolCallId: decodeURIComponent(toolCallId),
filePath: decodeHex(filePath).toString(),
part
};
}
/**
* Tracks file edits made by tools in a session by snapshotting file content
* before and after each edit tool invocation.
* before and after each edit tool invocation, persisting snapshots into the
* session database.
*/
export class FileEditTracker {
@@ -20,45 +64,41 @@ export class FileEditTracker {
* Pending edits keyed by file path. The `onPreToolUse` hook stores
* entries here; `completeEdit` pops them when the tool finishes.
*/
private readonly _pendingEdits = new Map<string, { editKey: string; beforeUri: URI; snapshotDone: Promise<void> }>();
private readonly _pendingEdits = new Map<string, { beforeContent: VSBuffer; snapshotDone: Promise<void> }>();
/**
* Completed edits keyed by file path. The `onPostToolUse` hook stores
* entries here; `takeCompletedEdit` retrieves them synchronously from
* the `onToolComplete` handler.
* entries here; `takeCompletedEdit` retrieves them from the
* `onToolComplete` handler and persists to the database.
*/
private readonly _completedEdits = new Map<string, IToolResultFileEditContent>();
private readonly _completedEdits = new Map<string, { beforeContent: VSBuffer; afterContent: VSBuffer }>();
constructor(
private readonly _sessionId: string,
private readonly _sessionDataService: ISessionDataService,
private readonly _sessionUri: string,
private readonly _db: ISessionDatabase,
private readonly _fileService: IFileService,
private readonly _logService: ILogService,
) { }
/**
* Call from the `onPreToolUse` hook before an edit tool runs.
* Snapshots the file's current content as the "before" state.
* Reads the file's current content into memory as the "before" state.
* The hook blocks the SDK until this returns, ensuring the snapshot
* captures pre-edit content.
*
* @param filePath - Absolute path of the file being edited.
*/
async trackEditStart(filePath: string): Promise<void> {
const editKey = generateEditKey();
const sessionDataDir = this._sessionDataService.getSessionDataDirById(this._sessionId);
const beforeUri = URI.joinPath(sessionDataDir, 'file-edits', editKey, 'before');
const snapshotDone = this._snapshotFile(filePath, beforeUri);
this._pendingEdits.set(filePath, { editKey, beforeUri, snapshotDone });
await snapshotDone;
const snapshotDone = this._readFile(filePath);
const entry = { beforeContent: VSBuffer.fromString(''), snapshotDone: snapshotDone.then(buf => { entry.beforeContent = buf; }) };
this._pendingEdits.set(filePath, entry);
await entry.snapshotDone;
}
/**
* Call from the `onPostToolUse` hook after an edit tool finishes.
* Stores the result for later synchronous retrieval via {@link takeCompletedEdit}.
* The `beforeURI` points to the stored snapshot; the `afterURI` is
* the real file path (the tool already modified it on disk).
* Reads the file content again as the "after" state and stores the
* result for later retrieval via {@link takeCompletedEdit}.
*
* @param filePath - Absolute path of the file that was edited.
*/
@@ -70,44 +110,56 @@ export class FileEditTracker {
this._pendingEdits.delete(filePath);
await pending.snapshotDone;
// Snapshot the after-content into session data so it remains
// stable even if the file is modified again later.
const sessionDataDir = this._sessionDataService.getSessionDataDirById(this._sessionId);
const afterUri = URI.joinPath(sessionDataDir, 'file-edits', pending.editKey, 'after');
await this._snapshotFile(filePath, afterUri);
const afterContent = await this._readFile(filePath);
this._completedEdits.set(filePath, {
type: ToolResultContentType.FileEdit,
beforeURI: pending.beforeUri.toString(),
afterURI: afterUri.toString(),
beforeContent: pending.beforeContent,
afterContent,
});
}
/**
* Synchronously retrieves and removes a completed edit for the given
* file path. Call from the `onToolComplete` handler to include the
* edit in the tool result without async work.
* Retrieves and removes a completed edit for the given file path,
* persists it to the session database, and returns the result as an
* {@link IToolResultFileEditContent} for inclusion in the tool result.
*
* @param toolCallId - The tool call that produced this edit.
* @param filePath - Absolute path of the edited file.
*/
takeCompletedEdit(filePath: string): IToolResultFileEditContent | undefined {
takeCompletedEdit(turnId: string, toolCallId: string, filePath: string): IToolResultFileEditContent | undefined {
const edit = this._completedEdits.get(filePath);
if (edit) {
this._completedEdits.delete(filePath);
if (!edit) {
return undefined;
}
return edit;
this._completedEdits.delete(filePath);
const beforeBytes = edit.beforeContent.buffer;
const afterBytes = edit.afterContent.buffer;
this._db.storeFileEdit({
turnId,
toolCallId,
filePath,
beforeContent: beforeBytes,
afterContent: afterBytes,
addedLines: undefined,
removedLines: undefined,
}).catch(err => this._logService.warn(`[FileEditTracker] Failed to persist file edit to database: ${filePath}`, err));
return {
type: ToolResultContentType.FileEdit,
beforeURI: buildSessionDbUri(this._sessionUri, toolCallId, filePath, 'before'),
afterURI: buildSessionDbUri(this._sessionUri, toolCallId, filePath, 'after'),
};
}
private async _snapshotFile(filePath: string, targetUri: URI): Promise<void> {
private async _readFile(filePath: string): Promise<VSBuffer> {
try {
const content = await this._fileService.readFile(URI.file(filePath));
await this._fileService.writeFile(targetUri, content.value);
return content.value;
} catch (err) {
this._logService.trace(`[FileEditTracker] Could not read file for snapshot: ${filePath}`, err);
await this._fileService.writeFile(targetUri, VSBuffer.fromString('')).catch(() => { });
return VSBuffer.fromString('');
}
}
}
let _editKeyCounter = 0;
function generateEditKey(): string {
return `${Date.now()}-${_editKeyCounter++}`;
}

View File

@@ -0,0 +1,216 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { URI } from '../../../../base/common/uri.js';
import { IAgentMessageEvent, IAgentToolCompleteEvent, IAgentToolStartEvent } from '../../common/agentService.js';
import { IFileEditRecord, ISessionDatabase } from '../../common/sessionDataService.js';
import { ToolResultContentType, type IToolResultContent } from '../../common/state/sessionState.js';
import { getInvocationMessage, getPastTenseMessage, getShellLanguage, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool } from './copilotToolDisplay.js';
import { buildSessionDbUri } from './fileEditTracker.js';
function tryStringify(value: unknown): string | undefined {
try {
return JSON.stringify(value);
} catch {
return undefined;
}
}
// ---- Minimal event shapes matching the SDK's SessionEvent union ---------
// Defined here so tests can construct events without importing the SDK.
export interface ISessionEventToolStart {
type: 'tool.execution_start';
data: {
toolCallId: string;
toolName: string;
arguments?: unknown;
mcpServerName?: string;
mcpToolName?: string;
parentToolCallId?: string;
};
}
export interface ISessionEventToolComplete {
type: 'tool.execution_complete';
data: {
toolCallId: string;
success: boolean;
result?: { content?: string };
error?: { message: string; code?: string };
isUserRequested?: boolean;
toolTelemetry?: unknown;
parentToolCallId?: string;
};
}
export interface ISessionEventMessage {
type: 'assistant.message' | 'user.message';
data?: {
messageId?: string;
content?: string;
toolRequests?: readonly { toolCallId: string; name: string; arguments?: unknown; type?: 'function' | 'custom' }[];
reasoningOpaque?: string;
reasoningText?: string;
encryptedContent?: string;
parentToolCallId?: string;
};
}
/** Minimal event shape for session history mapping. */
export type ISessionEvent = ISessionEventToolStart | ISessionEventToolComplete | ISessionEventMessage | { type: string; data?: unknown };
/**
* Maps raw SDK session events into agent protocol events, restoring
* stored file-edit metadata from the session database when available.
*
* Extracted as a standalone function so it can be tested without the
* full CopilotAgent or SDK dependencies.
*/
export async function mapSessionEvents(
session: URI,
db: ISessionDatabase | undefined,
events: readonly ISessionEvent[],
): Promise<(IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent)[]> {
const result: (IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent)[] = [];
const toolInfoByCallId = new Map<string, { toolName: string; parameters: Record<string, unknown> | undefined }>();
// Collect all tool call IDs for edit tools so we can batch-query the database
const editToolCallIds: string[] = [];
// First pass: collect tool info and identify edit tool calls
for (const e of events) {
if (e.type === 'tool.execution_start') {
const d = (e as ISessionEventToolStart).data;
if (isHiddenTool(d.toolName)) {
continue;
}
const toolArgs = d.arguments !== undefined ? tryStringify(d.arguments) : undefined;
let parameters: Record<string, unknown> | undefined;
if (toolArgs) {
try { parameters = JSON.parse(toolArgs) as Record<string, unknown>; } catch { /* ignore */ }
}
toolInfoByCallId.set(d.toolCallId, { toolName: d.toolName, parameters });
if (isEditTool(d.toolName)) {
editToolCallIds.push(d.toolCallId);
}
}
}
// Query the database for stored file edits (metadata only)
let storedEdits: Map<string, IFileEditRecord[]> | undefined;
if (db && editToolCallIds.length > 0) {
try {
const records = await db.getFileEdits(editToolCallIds);
if (records.length > 0) {
storedEdits = new Map();
for (const r of records) {
let list = storedEdits.get(r.toolCallId);
if (!list) {
list = [];
storedEdits.set(r.toolCallId, list);
}
list.push(r);
}
}
} catch {
// Database may not exist yet for new sessions — that's fine
}
}
const sessionUriStr = session.toString();
// Second pass: build result events
for (const e of events) {
if (e.type === 'assistant.message' || e.type === 'user.message') {
const d = (e as ISessionEventMessage).data;
result.push({
session,
type: 'message',
role: e.type === 'user.message' ? 'user' : 'assistant',
messageId: d?.messageId ?? '',
content: d?.content ?? '',
toolRequests: d?.toolRequests?.map((tr) => ({
toolCallId: tr.toolCallId,
name: tr.name,
arguments: tr.arguments !== undefined ? tryStringify(tr.arguments) : undefined,
type: tr.type,
})),
reasoningOpaque: d?.reasoningOpaque,
reasoningText: d?.reasoningText,
encryptedContent: d?.encryptedContent,
parentToolCallId: d?.parentToolCallId,
});
} else if (e.type === 'tool.execution_start') {
const d = (e as ISessionEventToolStart).data;
if (isHiddenTool(d.toolName)) {
continue;
}
const info = toolInfoByCallId.get(d.toolCallId);
const displayName = getToolDisplayName(d.toolName);
const toolKind = getToolKind(d.toolName);
const toolArgs = d.arguments !== undefined ? tryStringify(d.arguments) : undefined;
result.push({
session,
type: 'tool_start',
toolCallId: d.toolCallId,
toolName: d.toolName,
displayName,
invocationMessage: getInvocationMessage(d.toolName, displayName, info?.parameters),
toolInput: getToolInputString(d.toolName, info?.parameters, toolArgs),
toolKind,
language: toolKind === 'terminal' ? getShellLanguage(d.toolName) : undefined,
toolArguments: toolArgs,
mcpServerName: d.mcpServerName,
mcpToolName: d.mcpToolName,
parentToolCallId: d.parentToolCallId,
});
} else if (e.type === 'tool.execution_complete') {
const d = (e as ISessionEventToolComplete).data;
const info = toolInfoByCallId.get(d.toolCallId);
if (!info) {
continue;
}
toolInfoByCallId.delete(d.toolCallId);
const displayName = getToolDisplayName(info.toolName);
const toolOutput = d.error?.message ?? d.result?.content;
const content: IToolResultContent[] = [];
if (toolOutput !== undefined) {
content.push({ type: ToolResultContentType.Text, text: toolOutput });
}
// Restore file edit content references from the database
const edits = storedEdits?.get(d.toolCallId);
if (edits) {
for (const edit of edits) {
content.push({
type: ToolResultContentType.FileEdit,
beforeURI: buildSessionDbUri(sessionUriStr, edit.toolCallId, edit.filePath, 'before'),
afterURI: buildSessionDbUri(sessionUriStr, edit.toolCallId, edit.filePath, 'after'),
diff: (edit.addedLines !== undefined || edit.removedLines !== undefined)
? { added: edit.addedLines, removed: edit.removedLines }
: undefined,
});
}
}
result.push({
session,
type: 'tool_complete',
toolCallId: d.toolCallId,
result: {
success: d.success,
pastTenseMessage: getPastTenseMessage(info.toolName, displayName, info.parameters, d.success),
content: content.length > 0 ? content : undefined,
error: d.error,
},
isUserRequested: d.isUserRequested,
toolTelemetry: d.toolTelemetry !== undefined ? tryStringify(d.toolTelemetry) : undefined,
parentToolCallId: d.parentToolCallId,
});
}
}
return result;
}

View File

@@ -3,11 +3,32 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { IReference, ReferenceCollection } from '../../../base/common/lifecycle.js';
import { URI } from '../../../base/common/uri.js';
import { IFileService } from '../../files/common/files.js';
import { ILogService } from '../../log/common/log.js';
import { AgentSession } from '../common/agentService.js';
import { ISessionDataService } from '../common/sessionDataService.js';
import { ISessionDatabase, ISessionDataService } from '../common/sessionDataService.js';
import { SessionDatabase } from './sessionDatabase.js';
class SessionDatabaseCollection extends ReferenceCollection<ISessionDatabase> {
constructor(
private readonly _getDbPath: (key: string) => string,
private readonly _logService: ILogService,
) {
super();
}
protected createReferencedObject(key: string): ISessionDatabase {
const dbPath = this._getDbPath(key);
this._logService.trace(`[SessionDataService] Opening database: ${dbPath}`);
return new SessionDatabase(dbPath);
}
protected destroyReferencedObject(_key: string, object: ISessionDatabase): void {
object.dispose();
}
}
/**
* Implementation of {@link ISessionDataService} that stores per-session data
@@ -17,6 +38,7 @@ export class SessionDataService implements ISessionDataService {
declare readonly _serviceBrand: undefined;
private readonly _basePath: URI;
private readonly _databases: SessionDatabaseCollection;
constructor(
userDataPath: URI,
@@ -24,6 +46,10 @@ export class SessionDataService implements ISessionDataService {
@ILogService private readonly _logService: ILogService,
) {
this._basePath = URI.joinPath(userDataPath, 'agentSessionData');
this._databases = new SessionDatabaseCollection(
key => URI.joinPath(this._basePath, key, 'session.db').fsPath,
this._logService,
);
}
getSessionDataDir(session: URI): URI {
@@ -35,6 +61,11 @@ export class SessionDataService implements ISessionDataService {
return URI.joinPath(this._basePath, sanitized);
}
openDatabase(session: URI): IReference<ISessionDatabase> {
const sanitized = AgentSession.id(session).replace(/[^a-zA-Z0-9_.-]/g, '-');
return this._databases.acquire(sanitized);
}
async deleteSessionData(session: URI): Promise<void> {
const dir = this.getSessionDataDir(session);
try {

View File

@@ -0,0 +1,315 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as fs from 'fs';
import { SequencerByKey } from '../../../base/common/async.js';
import type { Database, RunResult } from '@vscode/sqlite3';
import type { IFileEditContent, IFileEditRecord, ISessionDatabase } from '../common/sessionDataService.js';
import { dirname } from '../../../base/common/path.js';
/**
* A single numbered migration. Migrations are applied in order of
* {@link version} and tracked via `PRAGMA user_version`.
*/
export interface ISessionDatabaseMigration {
/** Monotonically-increasing version number (1-based). */
readonly version: number;
/** SQL to execute for this migration. */
readonly sql: string;
}
/**
* The set of migrations that define the current session database schema.
* New migrations should be **appended** to this array with the next version
* number. Never reorder or mutate existing entries.
*/
export const sessionDatabaseMigrations: readonly ISessionDatabaseMigration[] = [
{
version: 1,
sql: [
`CREATE TABLE IF NOT EXISTS turns (
id TEXT PRIMARY KEY NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS file_edits (
turn_id TEXT NOT NULL REFERENCES turns(id) ON DELETE CASCADE,
tool_call_id TEXT NOT NULL,
file_path TEXT NOT NULL,
before_content BLOB NOT NULL,
after_content BLOB NOT NULL,
added_lines INTEGER,
removed_lines INTEGER,
PRIMARY KEY (tool_call_id, file_path)
)`,
].join(';\n'),
},
];
// ---- Promise wrappers around callback-based @vscode/sqlite3 API -----------
function dbExec(db: Database, sql: string): Promise<void> {
return new Promise((resolve, reject) => {
db.exec(sql, err => err ? reject(err) : resolve());
});
}
function dbRun(db: Database, sql: string, params: unknown[]): Promise<{ changes: number; lastID: number }> {
return new Promise((resolve, reject) => {
db.run(sql, params, function (this: RunResult, err: Error | null) {
if (err) {
return reject(err);
}
resolve({ changes: this.changes, lastID: this.lastID });
});
});
}
function dbGet(db: Database, sql: string, params: unknown[]): Promise<Record<string, unknown> | undefined> {
return new Promise((resolve, reject) => {
db.get(sql, params, (err: Error | null, row: Record<string, unknown> | undefined) => {
if (err) {
return reject(err);
}
resolve(row);
});
});
}
function dbAll(db: Database, sql: string, params: unknown[]): Promise<Record<string, unknown>[]> {
return new Promise((resolve, reject) => {
db.all(sql, params, (err: Error | null, rows: Record<string, unknown>[]) => {
if (err) {
return reject(err);
}
resolve(rows);
});
});
}
function dbClose(db: Database): Promise<void> {
return new Promise((resolve, reject) => {
db.close(err => err ? reject(err) : resolve());
});
}
function dbOpen(path: string): Promise<Database> {
return new Promise((resolve, reject) => {
import('@vscode/sqlite3').then(sqlite3 => {
const db = new sqlite3.default.Database(path, (err: Error | null) => {
if (err) {
return reject(err);
}
resolve(db);
});
}, reject);
});
}
/**
* Applies any pending {@link ISessionDatabaseMigration migrations} to a
* database. Migrations whose version is greater than the current
* `PRAGMA user_version` are run inside a serialized transaction. After all
* migrations complete the pragma is updated to the highest applied version.
*/
async function runMigrations(db: Database, migrations: readonly ISessionDatabaseMigration[]): Promise<void> {
// Enable foreign key enforcement — must be set outside a transaction
// and every time a connection is opened.
await dbExec(db, 'PRAGMA foreign_keys = ON');
const row = await dbGet(db, 'PRAGMA user_version', []);
const currentVersion = (row?.user_version as number | undefined) ?? 0;
const pending = migrations
.filter(m => m.version > currentVersion)
.sort((a, b) => a.version - b.version);
if (pending.length === 0) {
return;
}
await dbExec(db, 'BEGIN TRANSACTION');
try {
for (const migration of pending) {
await dbExec(db, migration.sql);
// PRAGMA cannot be parameterized; the version is a trusted literal.
await dbExec(db, `PRAGMA user_version = ${migration.version}`);
}
await dbExec(db, 'COMMIT');
} catch (err) {
await dbExec(db, 'ROLLBACK');
throw err;
}
}
/**
* A wrapper around a `@vscode/sqlite3` {@link Database} instance with
* lazy initialisation.
*
* The underlying connection is opened on the first async method call
* (not at construction time), allowing the object to be created
* synchronously and shared via a {@link ReferenceCollection}.
*
* Calling {@link dispose} closes the connection.
*/
export class SessionDatabase implements ISessionDatabase {
private _dbPromise: Promise<Database> | undefined;
private _disposed = false;
private readonly _fileEditSequencer = new SequencerByKey<string>();
constructor(
private readonly _path: string,
private readonly _migrations: readonly ISessionDatabaseMigration[] = sessionDatabaseMigrations,
) { }
/**
* Opens (or creates) a SQLite database at {@link path} and applies
* any pending migrations. Only used in tests where synchronous
* construction + immediate readiness is desired.
*/
static async open(path: string, migrations: readonly ISessionDatabaseMigration[] = sessionDatabaseMigrations): Promise<SessionDatabase> {
const inst = new SessionDatabase(path, migrations);
await inst._ensureDb();
return inst;
}
private _ensureDb(): Promise<Database> {
if (this._disposed) {
return Promise.reject(new Error('SessionDatabase has been disposed'));
}
if (!this._dbPromise) {
this._dbPromise = (async () => {
// Ensure the parent directory exists before SQLite tries to
// create the database file.
await fs.promises.mkdir(dirname(this._path), { recursive: true });
const db = await dbOpen(this._path);
try {
await runMigrations(db, this._migrations);
} catch (err) {
await dbClose(db);
this._dbPromise = undefined;
throw err;
}
// If dispose() was called while we were opening, close immediately.
if (this._disposed) {
await dbClose(db);
throw new Error('SessionDatabase has been disposed');
}
return db;
})();
}
return this._dbPromise;
}
/**
* Returns the names of all user-created tables in the database.
* Useful for testing migration behavior.
*/
async getAllTables(): Promise<string[]> {
const db = await this._ensureDb();
const rows = await dbAll(db, `SELECT name FROM sqlite_master WHERE type='table' ORDER BY name`, []);
return rows.map(r => r.name as string);
}
// ---- Turns ----------------------------------------------------------
async createTurn(turnId: string): Promise<void> {
const db = await this._ensureDb();
await dbRun(db, 'INSERT OR IGNORE INTO turns (id) VALUES (?)', [turnId]);
}
async deleteTurn(turnId: string): Promise<void> {
const db = await this._ensureDb();
await dbRun(db, 'DELETE FROM turns WHERE id = ?', [turnId]);
}
// ---- File edits -----------------------------------------------------
async storeFileEdit(edit: IFileEditRecord & IFileEditContent): Promise<void> {
return this._fileEditSequencer.queue(edit.filePath, async () => {
const db = await this._ensureDb();
// Ensure the turn exists — the onTurnStart event that calls
// createTurn() is fire-and-forget and may not have completed yet.
await dbRun(db, 'INSERT OR IGNORE INTO turns (id) VALUES (?)', [edit.turnId]);
await dbRun(
db,
`INSERT OR REPLACE INTO file_edits
(turn_id, tool_call_id, file_path, before_content, after_content, added_lines, removed_lines)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[
edit.turnId,
edit.toolCallId,
edit.filePath,
Buffer.from(edit.beforeContent),
Buffer.from(edit.afterContent),
edit.addedLines ?? null,
edit.removedLines ?? null,
],
);
});
}
async getFileEdits(toolCallIds: string[]): Promise<IFileEditRecord[]> {
if (toolCallIds.length === 0) {
return [];
}
const db = await this._ensureDb();
const placeholders = toolCallIds.map(() => '?').join(',');
const rows = await dbAll(
db,
`SELECT turn_id, tool_call_id, file_path, added_lines, removed_lines
FROM file_edits
WHERE tool_call_id IN (${placeholders})
ORDER BY rowid`,
toolCallIds,
);
return rows.map(row => ({
turnId: row.turn_id as string,
toolCallId: row.tool_call_id as string,
filePath: row.file_path as string,
addedLines: row.added_lines as number | undefined ?? undefined,
removedLines: row.removed_lines as number | undefined ?? undefined,
}));
}
async readFileEditContent(toolCallId: string, filePath: string): Promise<IFileEditContent | undefined> {
return this._fileEditSequencer.queue(filePath, async () => {
const db = await this._ensureDb();
const row = await dbGet(
db,
`SELECT before_content, after_content
FROM file_edits
WHERE tool_call_id = ? AND file_path = ?`,
[toolCallId, filePath],
);
if (!row) {
return undefined;
}
return {
beforeContent: toUint8Array(row.before_content),
afterContent: toUint8Array(row.after_content),
};
});
}
dispose(): void {
if (!this._disposed) {
this._disposed = true;
this._dbPromise?.then(db => db.close()).catch(() => { });
}
}
}
function toUint8Array(value: unknown): Uint8Array {
if (value instanceof Buffer) {
return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
}
if (value instanceof Uint8Array) {
return value;
}
if (typeof value === 'string') {
return new TextEncoder().encode(value);
}
return new Uint8Array(0);
}

View File

@@ -143,7 +143,9 @@ export class SessionStateManager extends Disposable {
}
/**
* Removes a session from state and emits a sessionRemoved notification.
* Removes a session from in-memory state without emitting a notification.
* Use {@link deleteSession} when the session is being permanently deleted
* and clients need to be notified.
*/
removeSession(session: URI): void {
const state = this._sessionStates.get(session);
@@ -158,7 +160,15 @@ export class SessionStateManager extends Disposable {
this._sessionStates.delete(session);
this._logService.trace(`[SessionStateManager] Removed session: ${session}`);
}
/**
* Permanently deletes a session from state and emits a
* {@link NotificationType.SessionRemoved} notification so that clients
* know the session is no longer accessible.
*/
deleteSession(session: URI): void {
this.removeSession(session);
this._onDidEmitNotification.fire({
type: NotificationType.SessionRemoved,
session,

View File

@@ -26,6 +26,7 @@ suite('AgentService (node dispatcher)', () => {
_serviceBrand: undefined,
getSessionDataDir: () => URI.parse('inmemory:/session-data'),
getSessionDataDirById: () => URI.parse('inmemory:/session-data'),
openDatabase: () => { throw new Error('not implemented'); },
deleteSessionData: async () => { },
cleanupOrphanedData: async () => { },
};

View File

@@ -74,6 +74,7 @@ suite('AgentSideEffects', () => {
_serviceBrand: undefined,
getSessionDataDir: () => URI.from({ scheme: Schemas.inMemory, path: '/session-data' }),
getSessionDataDirById: () => URI.from({ scheme: Schemas.inMemory, path: '/session-data' }),
openDatabase: () => { throw new Error('not implemented'); },
deleteSessionData: async () => { },
cleanupOrphanedData: async () => { },
} satisfies ISessionDataService,

View File

@@ -4,89 +4,161 @@
*--------------------------------------------------------------------------------------------*/
import assert from 'assert';
import { tmpdir } from 'os';
import { randomUUID } from 'crypto';
import { mkdirSync, rmSync } from 'fs';
import { VSBuffer } from '../../../../base/common/buffer.js';
import { DisposableStore } from '../../../../base/common/lifecycle.js';
import { Schemas } from '../../../../base/common/network.js';
import { URI } from '../../../../base/common/uri.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
import { FileService } from '../../../files/common/fileService.js';
import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js';
import { NullLogService } from '../../../log/common/log.js';
import { ISessionDataService } from '../../common/sessionDataService.js';
import { ToolResultContentType } from '../../common/state/sessionState.js';
import { SessionDataService } from '../../node/sessionDataService.js';
import { FileEditTracker } from '../../node/copilot/fileEditTracker.js';
import { SessionDatabase } from '../../node/sessionDatabase.js';
import { FileEditTracker, buildSessionDbUri, parseSessionDbUri } from '../../node/copilot/fileEditTracker.js';
import { join } from '../../../../base/common/path.js';
suite('FileEditTracker', () => {
const disposables = new DisposableStore();
let fileService: FileService;
let sessionDataService: ISessionDataService;
let db: SessionDatabase;
let tracker: FileEditTracker;
let testDir: string;
const basePath = URI.from({ scheme: Schemas.inMemory, path: '/userData' });
setup(async () => {
testDir = join(tmpdir(), `vscode-edit-tracker-test-${randomUUID()}`);
mkdirSync(testDir, { recursive: true });
setup(() => {
fileService = disposables.add(new FileService(new NullLogService()));
disposables.add(fileService.registerProvider(Schemas.inMemory, disposables.add(new InMemoryFileSystemProvider())));
sessionDataService = new SessionDataService(basePath, fileService, new NullLogService());
tracker = new FileEditTracker('test-session', sessionDataService, fileService, new NullLogService());
const sourceFs = disposables.add(new InMemoryFileSystemProvider());
disposables.add(fileService.registerProvider('file', sourceFs));
db = disposables.add(await SessionDatabase.open(join(testDir, 'session.db')));
await db.createTurn('turn-1');
tracker = new FileEditTracker('copilot:/test-session', db, fileService, new NullLogService());
});
teardown(() => disposables.clear());
teardown(() => {
disposables.clear();
rmSync(testDir, { recursive: true, force: true });
});
ensureNoDisposablesAreLeakedInTestSuite();
test('tracks edit start and complete for existing file', async () => {
const sourceFs = disposables.add(new InMemoryFileSystemProvider());
disposables.add(fileService.registerProvider(Schemas.file, sourceFs));
await fileService.writeFile(URI.file('/workspace/test.txt'), VSBuffer.fromString('original content\nline 2'));
await tracker.trackEditStart('/workspace/test.txt');
await fileService.writeFile(URI.file('/workspace/test.txt'), VSBuffer.fromString('modified content\nline 2\nline 3'));
await tracker.completeEdit('/workspace/test.txt');
const fileEdit = tracker.takeCompletedEdit('/workspace/test.txt');
const fileEdit = await tracker.takeCompletedEdit('turn-1', 'tc-1', '/workspace/test.txt');
assert.ok(fileEdit);
assert.strictEqual(fileEdit.type, ToolResultContentType.FileEdit);
// Both URIs point to snapshots in the session data directory
const sessionDir = sessionDataService.getSessionDataDirById('test-session');
assert.ok(fileEdit.beforeURI.startsWith(sessionDir.toString()));
assert.ok(fileEdit.afterURI.startsWith(sessionDir.toString()));
// URIs are parseable session-db: URIs
const beforeFields = parseSessionDbUri(fileEdit.beforeURI);
assert.ok(beforeFields);
assert.strictEqual(beforeFields.sessionUri, 'copilot:/test-session');
assert.strictEqual(beforeFields.toolCallId, 'tc-1');
assert.strictEqual(beforeFields.filePath, '/workspace/test.txt');
assert.strictEqual(beforeFields.part, 'before');
const afterFields = parseSessionDbUri(fileEdit.afterURI);
assert.ok(afterFields);
assert.strictEqual(afterFields.part, 'after');
// Content is persisted in the database (wait for fire-and-forget write)
await new Promise(r => setTimeout(r, 50));
const content = await db.readFileEditContent('tc-1', '/workspace/test.txt');
assert.ok(content);
assert.strictEqual(new TextDecoder().decode(content.beforeContent), 'original content\nline 2');
assert.strictEqual(new TextDecoder().decode(content.afterContent), 'modified content\nline 2\nline 3');
});
test('tracks edit for newly created file (no before content)', async () => {
const sourceFs = disposables.add(new InMemoryFileSystemProvider());
disposables.add(fileService.registerProvider(Schemas.file, sourceFs));
await tracker.trackEditStart('/workspace/new-file.txt');
await fileService.writeFile(URI.file('/workspace/new-file.txt'), VSBuffer.fromString('new file\ncontent'));
await tracker.completeEdit('/workspace/new-file.txt');
const fileEdit = tracker.takeCompletedEdit('/workspace/new-file.txt');
const fileEdit = await tracker.takeCompletedEdit('turn-1', 'tc-2', '/workspace/new-file.txt');
assert.ok(fileEdit);
const sessionDir = sessionDataService.getSessionDataDirById('test-session');
assert.ok(fileEdit.afterURI.startsWith(sessionDir.toString()));
// Wait for the fire-and-forget DB write to complete
await new Promise(r => setTimeout(r, 50));
const content = await db.readFileEditContent('tc-2', '/workspace/new-file.txt');
assert.ok(content);
assert.strictEqual(new TextDecoder().decode(content.beforeContent), '');
assert.strictEqual(new TextDecoder().decode(content.afterContent), 'new file\ncontent');
});
test('takeCompletedEdit returns undefined for unknown file path', () => {
const result = tracker.takeCompletedEdit('/nonexistent');
test('takeCompletedEdit returns undefined for unknown file path', async () => {
const result = await tracker.takeCompletedEdit('turn-1', 'tc-x', '/nonexistent');
assert.strictEqual(result, undefined);
});
test('before and after snapshot content can be read back', async () => {
const sourceFs = disposables.add(new InMemoryFileSystemProvider());
disposables.add(fileService.registerProvider(Schemas.file, sourceFs));
test('before and after content can be read from database', async () => {
await fileService.writeFile(URI.file('/workspace/file.ts'), VSBuffer.fromString('original'));
await tracker.trackEditStart('/workspace/file.ts');
await fileService.writeFile(URI.file('/workspace/file.ts'), VSBuffer.fromString('modified'));
await tracker.completeEdit('/workspace/file.ts');
const fileEdit = tracker.takeCompletedEdit('/workspace/file.ts');
assert.ok(fileEdit);
const beforeContent = await fileService.readFile(URI.parse(fileEdit.beforeURI));
assert.strictEqual(beforeContent.value.toString(), 'original');
const afterContent = await fileService.readFile(URI.parse(fileEdit.afterURI));
assert.strictEqual(afterContent.value.toString(), 'modified');
await tracker.takeCompletedEdit('turn-1', 'tc-3', '/workspace/file.ts');
// Wait for the fire-and-forget DB write to complete
await new Promise(r => setTimeout(r, 50));
const content = await db.readFileEditContent('tc-3', '/workspace/file.ts');
assert.ok(content);
assert.strictEqual(new TextDecoder().decode(content.beforeContent), 'original');
assert.strictEqual(new TextDecoder().decode(content.afterContent), 'modified');
});
});
suite('buildSessionDbUri / parseSessionDbUri', () => {
ensureNoDisposablesAreLeakedInTestSuite();
test('round-trips a simple URI', () => {
const uri = buildSessionDbUri('copilot:/abc-123', 'tc-1', '/workspace/file.ts', 'before');
const parsed = parseSessionDbUri(uri);
assert.ok(parsed);
assert.deepStrictEqual(parsed, {
sessionUri: 'copilot:/abc-123',
toolCallId: 'tc-1',
filePath: '/workspace/file.ts',
part: 'before',
});
});
test('round-trips with special characters in filePath', () => {
const uri = buildSessionDbUri('copilot:/s1', 'tc-2', '/work space/file (1).ts', 'after');
const parsed = parseSessionDbUri(uri);
assert.ok(parsed);
assert.strictEqual(parsed.filePath, '/work space/file (1).ts');
assert.strictEqual(parsed.part, 'after');
});
test('round-trips with special characters in toolCallId', () => {
const uri = buildSessionDbUri('copilot:/s1', 'call_abc=123&x', '/file.ts', 'before');
const parsed = parseSessionDbUri(uri);
assert.ok(parsed);
assert.strictEqual(parsed.toolCallId, 'call_abc=123&x');
});
test('parseSessionDbUri returns undefined for non-session-db URIs', () => {
assert.strictEqual(parseSessionDbUri('file:///foo/bar'), undefined);
assert.strictEqual(parseSessionDbUri('https://example.com'), undefined);
});
test('parseSessionDbUri returns undefined for malformed session-db URIs', () => {
assert.strictEqual(parseSessionDbUri('session-db:copilot:/s1'), undefined);
assert.strictEqual(parseSessionDbUri('session-db:copilot:/s1?toolCallId=tc-1'), undefined);
assert.strictEqual(parseSessionDbUri('session-db:copilot:/s1?toolCallId=tc-1&filePath=/f&part=middle'), undefined);
});
});

View File

@@ -0,0 +1,238 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import assert from 'assert';
import { tmpdir } from 'os';
import { randomUUID } from 'crypto';
import { mkdirSync, rmSync } from 'fs';
import { DisposableStore } from '../../../../base/common/lifecycle.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
import { AgentSession } from '../../common/agentService.js';
import { ToolResultContentType } from '../../common/state/sessionState.js';
import { SessionDatabase } from '../../node/sessionDatabase.js';
import { parseSessionDbUri } from '../../node/copilot/fileEditTracker.js';
import { mapSessionEvents, type ISessionEvent } from '../../node/copilot/mapSessionEvents.js';
import { join } from '../../../../base/common/path.js';
suite('mapSessionEvents', () => {
const disposables = new DisposableStore();
let testDir: string;
const session = AgentSession.uri('copilot', 'test-session');
setup(() => {
testDir = join(tmpdir(), `vscode-map-events-test-${randomUUID()}`);
mkdirSync(testDir, { recursive: true });
});
teardown(() => {
disposables.clear();
rmSync(testDir, { recursive: true, force: true });
});
ensureNoDisposablesAreLeakedInTestSuite();
function dbPath(): string {
return join(testDir, 'session.db');
}
// ---- Basic event mapping --------------------------------------------
test('maps user and assistant messages', async () => {
const events: ISessionEvent[] = [
{ type: 'user.message', data: { messageId: 'msg-1', content: 'hello' } },
{ type: 'assistant.message', data: { messageId: 'msg-2', content: 'world' } },
];
const result = await mapSessionEvents(session, undefined, events);
assert.strictEqual(result.length, 2);
assert.deepStrictEqual(result[0], {
session,
type: 'message',
role: 'user',
messageId: 'msg-1',
content: 'hello',
toolRequests: undefined,
reasoningOpaque: undefined,
reasoningText: undefined,
encryptedContent: undefined,
parentToolCallId: undefined,
});
assert.strictEqual(result[1].type, 'message');
assert.strictEqual((result[1] as { role: string }).role, 'assistant');
});
test('maps tool start and complete events', async () => {
const events: ISessionEvent[] = [
{
type: 'tool.execution_start',
data: { toolCallId: 'tc-1', toolName: 'shell', arguments: { command: 'echo hi' } },
},
{
type: 'tool.execution_complete',
data: { toolCallId: 'tc-1', success: true, result: { content: 'hi\n' } },
},
];
const result = await mapSessionEvents(session, undefined, events);
assert.strictEqual(result.length, 2);
assert.strictEqual(result[0].type, 'tool_start');
assert.strictEqual(result[1].type, 'tool_complete');
const complete = result[1] as { result: { content?: readonly { type: string; text?: string }[] } };
assert.ok(complete.result.content);
assert.strictEqual(complete.result.content[0].type, ToolResultContentType.Text);
});
test('skips tool_complete without matching tool_start', async () => {
const events: ISessionEvent[] = [
{ type: 'tool.execution_complete', data: { toolCallId: 'orphan', success: true } },
];
const result = await mapSessionEvents(session, undefined, events);
assert.strictEqual(result.length, 0);
});
test('ignores unknown event types', async () => {
const events: ISessionEvent[] = [
{ type: 'some.unknown.event', data: {} },
{ type: 'user.message', data: { messageId: 'msg-1', content: 'test' } },
];
const result = await mapSessionEvents(session, undefined, events);
assert.strictEqual(result.length, 1);
});
// ---- File edit restoration ------------------------------------------
suite('file edit restoration', () => {
test('restores file edits from database for edit tools', async () => {
const db = disposables.add(await SessionDatabase.open(dbPath()));
await db.createTurn('turn-1');
await db.storeFileEdit({
turnId: 'turn-1',
toolCallId: 'tc-edit',
filePath: '/workspace/file.ts',
beforeContent: new TextEncoder().encode('before'),
afterContent: new TextEncoder().encode('after'),
addedLines: 3,
removedLines: 1,
});
const events: ISessionEvent[] = [
{
type: 'tool.execution_start',
data: { toolCallId: 'tc-edit', toolName: 'edit', arguments: { filePath: '/workspace/file.ts' } },
},
{
type: 'tool.execution_complete',
data: { toolCallId: 'tc-edit', success: true, result: { content: 'Edited file.ts' } },
},
];
const result = await mapSessionEvents(session, db, events);
const complete = result[1];
assert.strictEqual(complete.type, 'tool_complete');
const content = (complete as { result: { content?: readonly Record<string, unknown>[] } }).result.content;
assert.ok(content);
// Should have text content + file edit
assert.strictEqual(content.length, 2);
assert.strictEqual(content[0].type, ToolResultContentType.Text);
assert.strictEqual(content[1].type, ToolResultContentType.FileEdit);
// File edit URIs should be parseable
const fileEdit = content[1] as { beforeURI: string; afterURI: string; diff?: { added?: number; removed?: number } };
const beforeFields = parseSessionDbUri(fileEdit.beforeURI);
assert.ok(beforeFields);
assert.strictEqual(beforeFields.toolCallId, 'tc-edit');
assert.strictEqual(beforeFields.filePath, '/workspace/file.ts');
assert.strictEqual(beforeFields.part, 'before');
assert.deepStrictEqual(fileEdit.diff, { added: 3, removed: 1 });
});
test('handles multiple file edits for one tool call', async () => {
const db = disposables.add(await SessionDatabase.open(dbPath()));
await db.createTurn('turn-1');
await db.storeFileEdit({
turnId: 'turn-1',
toolCallId: 'tc-multi',
filePath: '/workspace/a.ts',
beforeContent: new Uint8Array(0),
afterContent: new TextEncoder().encode('a'),
addedLines: undefined,
removedLines: undefined,
});
await db.storeFileEdit({
turnId: 'turn-1',
toolCallId: 'tc-multi',
filePath: '/workspace/b.ts',
beforeContent: new Uint8Array(0),
afterContent: new TextEncoder().encode('b'),
addedLines: undefined,
removedLines: undefined,
});
const events: ISessionEvent[] = [
{
type: 'tool.execution_start',
data: { toolCallId: 'tc-multi', toolName: 'write' },
},
{
type: 'tool.execution_complete',
data: { toolCallId: 'tc-multi', success: true },
},
];
const result = await mapSessionEvents(session, db, events);
const content = (result[1] as { result: { content?: readonly Record<string, unknown>[] } }).result.content;
assert.ok(content);
// Two file edits (no text since result had no content)
const fileEdits = content.filter(c => c.type === ToolResultContentType.FileEdit);
assert.strictEqual(fileEdits.length, 2);
});
test('works without database (no file edits restored)', async () => {
const events: ISessionEvent[] = [
{
type: 'tool.execution_start',
data: { toolCallId: 'tc-1', toolName: 'edit', arguments: { filePath: '/workspace/file.ts' } },
},
{
type: 'tool.execution_complete',
data: { toolCallId: 'tc-1', success: true, result: { content: 'done' } },
},
];
const result = await mapSessionEvents(session, undefined, events);
const content = (result[1] as { result: { content?: readonly Record<string, unknown>[] } }).result.content;
assert.ok(content);
// Only text content, no file edits
assert.strictEqual(content.length, 1);
assert.strictEqual(content[0].type, ToolResultContentType.Text);
});
test('non-edit tools do not get file edits even if db has data', async () => {
const db = disposables.add(await SessionDatabase.open(dbPath()));
const events: ISessionEvent[] = [
{
type: 'tool.execution_start',
data: { toolCallId: 'tc-1', toolName: 'shell', arguments: { command: 'ls' } },
},
{
type: 'tool.execution_complete',
data: { toolCallId: 'tc-1', success: true, result: { content: 'files' } },
},
];
const result = await mapSessionEvents(session, db, events);
const content = (result[1] as { result: { content?: readonly Record<string, unknown>[] } }).result.content;
assert.ok(content);
assert.strictEqual(content.length, 1);
assert.strictEqual(content[0].type, ToolResultContentType.Text);
});
});
});

View File

@@ -4,16 +4,21 @@
*--------------------------------------------------------------------------------------------*/
import assert from 'assert';
import { tmpdir } from 'os';
import { randomUUID } from 'crypto';
import { mkdirSync, rmSync } from 'fs';
import { VSBuffer } from '../../../../base/common/buffer.js';
import { DisposableStore } from '../../../../base/common/lifecycle.js';
import { Schemas } from '../../../../base/common/network.js';
import { URI } from '../../../../base/common/uri.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
import { FileService } from '../../../files/common/fileService.js';
import { DiskFileSystemProvider } from '../../../files/node/diskFileSystemProvider.js';
import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js';
import { NullLogService } from '../../../log/common/log.js';
import { AgentSession } from '../../common/agentService.js';
import { SessionDataService } from '../../node/sessionDataService.js';
import { join } from '../../../../base/common/path.js';
suite('SessionDataService', () => {
@@ -80,3 +85,72 @@ suite('SessionDataService', () => {
await service.cleanupOrphanedData(new Set());
});
});
suite('SessionDataService — openDatabase ref-counting', () => {
const disposables = new DisposableStore();
let service: SessionDataService;
let testDir: string;
setup(() => {
testDir = join(tmpdir(), `vscode-session-data-test-${randomUUID()}`);
mkdirSync(testDir, { recursive: true });
const fileService = disposables.add(new FileService(new NullLogService()));
disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new DiskFileSystemProvider(new NullLogService()))));
service = new SessionDataService(URI.file(testDir), fileService, new NullLogService());
});
teardown(() => {
disposables.clear();
rmSync(testDir, { recursive: true, force: true });
});
ensureNoDisposablesAreLeakedInTestSuite();
test('returns a functional database reference', async () => {
const session = AgentSession.uri('copilot', 'ref-test');
const ref = service.openDatabase(session);
disposables.add(ref);
await ref.object.createTurn('turn-1');
const edits = await ref.object.getFileEdits([]);
assert.deepStrictEqual(edits, []);
});
test('multiple references share the same database', async () => {
const session = AgentSession.uri('copilot', 'shared-test');
const ref1 = service.openDatabase(session);
const ref2 = service.openDatabase(session);
assert.strictEqual(ref1.object, ref2.object);
ref1.dispose();
ref2.dispose();
});
test('database remains usable until last reference is disposed', async () => {
const session = AgentSession.uri('copilot', 'refcount-test');
const ref1 = service.openDatabase(session);
const ref2 = service.openDatabase(session);
ref1.dispose();
// ref2 still works
await ref2.object.createTurn('turn-1');
ref2.dispose();
});
test('new reference after all disposed gets a fresh database', async () => {
const session = AgentSession.uri('copilot', 'reopen-test');
const ref1 = service.openDatabase(session);
const db1 = ref1.object;
ref1.dispose();
const ref2 = service.openDatabase(session);
disposables.add(ref2);
// New reference — may or may not be the same object, but must be functional
await ref2.object.createTurn('turn-1');
assert.notStrictEqual(ref2.object, db1);
});
});

View File

@@ -0,0 +1,421 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import assert from 'assert';
import { tmpdir } from 'os';
import { randomUUID } from 'crypto';
import { mkdirSync, rmSync } from 'fs';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
import { DisposableStore } from '../../../../base/common/lifecycle.js';
import { SessionDatabase, type ISessionDatabaseMigration } from '../../node/sessionDatabase.js';
import { join } from '../../../../base/common/path.js';
suite('SessionDatabase', () => {
const disposables = new DisposableStore();
let testDir: string;
setup(() => {
testDir = join(tmpdir(), `vscode-session-db-test-${randomUUID()}`);
mkdirSync(testDir, { recursive: true });
});
teardown(() => {
disposables.clear();
rmSync(testDir, { recursive: true, force: true });
});
ensureNoDisposablesAreLeakedInTestSuite();
function dbPath(name = 'session.db'): string {
return join(testDir, name);
}
// ---- Migration system -----------------------------------------------
suite('migrations', () => {
test('applies all migrations on a fresh database', async () => {
const migrations: ISessionDatabaseMigration[] = [
{ version: 1, sql: 'CREATE TABLE t1 (id INTEGER PRIMARY KEY)' },
{ version: 2, sql: 'CREATE TABLE t2 (id INTEGER PRIMARY KEY)' },
];
const db = disposables.add(await SessionDatabase.open(dbPath(), migrations));
const tables = (await db.getAllTables()).sort();
assert.deepStrictEqual(tables, ['t1', 't2']);
});
test('reopening with same migrations is a no-op', async () => {
const migrations: ISessionDatabaseMigration[] = [
{ version: 1, sql: 'CREATE TABLE t1 (id INTEGER PRIMARY KEY)' },
];
const db1 = await SessionDatabase.open(dbPath(), migrations);
db1.dispose();
// Reopen — should not throw (table already exists, migration skipped)
const db2 = disposables.add(await SessionDatabase.open(dbPath(), migrations));
assert.deepStrictEqual(await db2.getAllTables(), ['t1']);
});
test('only applies new migrations on reopen', async () => {
const v1: ISessionDatabaseMigration[] = [
{ version: 1, sql: 'CREATE TABLE t1 (id INTEGER PRIMARY KEY)' },
];
const db1 = await SessionDatabase.open(dbPath(), v1);
db1.dispose();
const v2: ISessionDatabaseMigration[] = [
...v1,
{ version: 2, sql: 'CREATE TABLE t2 (id INTEGER PRIMARY KEY)' },
];
const db2 = disposables.add(await SessionDatabase.open(dbPath(), v2));
const tables = (await db2.getAllTables()).sort();
assert.deepStrictEqual(tables, ['t1', 't2']);
});
test('rolls back on migration failure', async () => {
const migrations: ISessionDatabaseMigration[] = [
{ version: 1, sql: 'CREATE TABLE t1 (id INTEGER PRIMARY KEY)' },
{ version: 2, sql: 'THIS IS INVALID SQL' },
];
await assert.rejects(() => SessionDatabase.open(dbPath(), migrations));
// Reopen with only v1 — t1 should not exist because the whole
// transaction was rolled back
const db = disposables.add(await SessionDatabase.open(dbPath(), [
{ version: 1, sql: 'CREATE TABLE t1 (id INTEGER PRIMARY KEY)' },
]));
assert.deepStrictEqual(await db.getAllTables(), ['t1']);
});
});
// ---- File edits -----------------------------------------------------
suite('file edits', () => {
test('store and retrieve a file edit', async () => {
const db = disposables.add(await SessionDatabase.open(dbPath()));
await db.createTurn('turn-1');
await db.storeFileEdit({
turnId: 'turn-1',
toolCallId: 'tc-1',
filePath: '/workspace/file.ts',
beforeContent: new TextEncoder().encode('before'),
afterContent: new TextEncoder().encode('after'),
addedLines: 5,
removedLines: 2,
});
const edits = await db.getFileEdits(['tc-1']);
assert.deepStrictEqual(edits, [{
turnId: 'turn-1',
toolCallId: 'tc-1',
filePath: '/workspace/file.ts',
addedLines: 5,
removedLines: 2,
}]);
});
test('retrieve multiple edits for a single tool call', async () => {
const db = disposables.add(await SessionDatabase.open(dbPath()));
await db.createTurn('turn-1');
await db.storeFileEdit({
turnId: 'turn-1',
toolCallId: 'tc-1',
filePath: '/workspace/a.ts',
beforeContent: new TextEncoder().encode('a-before'),
afterContent: new TextEncoder().encode('a-after'),
addedLines: undefined,
removedLines: undefined,
});
await db.storeFileEdit({
turnId: 'turn-1',
toolCallId: 'tc-1',
filePath: '/workspace/b.ts',
beforeContent: new TextEncoder().encode('b-before'),
afterContent: new TextEncoder().encode('b-after'),
addedLines: 1,
removedLines: 0,
});
const edits = await db.getFileEdits(['tc-1']);
assert.strictEqual(edits.length, 2);
assert.strictEqual(edits[0].filePath, '/workspace/a.ts');
assert.strictEqual(edits[1].filePath, '/workspace/b.ts');
});
test('retrieve edits across multiple tool calls', async () => {
const db = disposables.add(await SessionDatabase.open(dbPath()));
await db.createTurn('turn-1');
await db.storeFileEdit({
turnId: 'turn-1',
toolCallId: 'tc-1',
filePath: '/workspace/a.ts',
beforeContent: new Uint8Array(0),
afterContent: new TextEncoder().encode('hello'),
addedLines: undefined,
removedLines: undefined,
});
await db.storeFileEdit({
turnId: 'turn-1',
toolCallId: 'tc-2',
filePath: '/workspace/b.ts',
beforeContent: new Uint8Array(0),
afterContent: new TextEncoder().encode('world'),
addedLines: undefined,
removedLines: undefined,
});
const edits = await db.getFileEdits(['tc-1', 'tc-2']);
assert.strictEqual(edits.length, 2);
// Only tc-2
const edits2 = await db.getFileEdits(['tc-2']);
assert.strictEqual(edits2.length, 1);
assert.strictEqual(edits2[0].toolCallId, 'tc-2');
});
test('returns empty array for unknown tool call IDs', async () => {
const db = disposables.add(await SessionDatabase.open(dbPath()));
const edits = await db.getFileEdits(['nonexistent']);
assert.deepStrictEqual(edits, []);
});
test('returns empty array when given empty array', async () => {
const db = disposables.add(await SessionDatabase.open(dbPath()));
const edits = await db.getFileEdits([]);
assert.deepStrictEqual(edits, []);
});
test('replace on conflict (same toolCallId + filePath)', async () => {
const db = disposables.add(await SessionDatabase.open(dbPath()));
await db.createTurn('turn-1');
await db.storeFileEdit({
turnId: 'turn-1',
toolCallId: 'tc-1',
filePath: '/workspace/file.ts',
beforeContent: new TextEncoder().encode('v1'),
afterContent: new TextEncoder().encode('v1-after'),
addedLines: 1,
removedLines: 0,
});
await db.storeFileEdit({
turnId: 'turn-1',
toolCallId: 'tc-1',
filePath: '/workspace/file.ts',
beforeContent: new TextEncoder().encode('v2'),
afterContent: new TextEncoder().encode('v2-after'),
addedLines: 3,
removedLines: 1,
});
const edits = await db.getFileEdits(['tc-1']);
assert.strictEqual(edits.length, 1);
assert.strictEqual(edits[0].addedLines, 3);
const content = await db.readFileEditContent('tc-1', '/workspace/file.ts');
assert.ok(content);
assert.deepStrictEqual(new TextDecoder().decode(content.beforeContent), 'v2');
});
test('readFileEditContent returns content on demand', async () => {
const db = disposables.add(await SessionDatabase.open(dbPath()));
await db.createTurn('turn-1');
await db.storeFileEdit({
turnId: 'turn-1',
toolCallId: 'tc-1',
filePath: '/workspace/file.ts',
beforeContent: new TextEncoder().encode('before'),
afterContent: new TextEncoder().encode('after'),
addedLines: undefined,
removedLines: undefined,
});
const content = await db.readFileEditContent('tc-1', '/workspace/file.ts');
assert.ok(content);
assert.deepStrictEqual(content.beforeContent, new TextEncoder().encode('before'));
assert.deepStrictEqual(content.afterContent, new TextEncoder().encode('after'));
});
test('readFileEditContent returns undefined for missing edit', async () => {
const db = disposables.add(await SessionDatabase.open(dbPath()));
const content = await db.readFileEditContent('tc-missing', '/no/such/file');
assert.strictEqual(content, undefined);
});
test('persists binary content correctly', async () => {
const db = disposables.add(await SessionDatabase.open(dbPath()));
const binary = new Uint8Array([0, 1, 2, 255, 128, 64]);
await db.createTurn('turn-1');
await db.storeFileEdit({
turnId: 'turn-1',
toolCallId: 'tc-bin',
filePath: '/workspace/image.png',
beforeContent: new Uint8Array(0),
afterContent: binary,
addedLines: undefined,
removedLines: undefined,
});
const content = await db.readFileEditContent('tc-bin', '/workspace/image.png');
assert.ok(content);
assert.deepStrictEqual(content.afterContent, binary);
});
test('auto-creates turn if it does not exist', async () => {
const db = disposables.add(await SessionDatabase.open(dbPath()));
// storeFileEdit should succeed even without a prior createTurn call
await db.storeFileEdit({
turnId: 'auto-turn',
toolCallId: 'tc-1',
filePath: '/x',
beforeContent: new Uint8Array(0),
afterContent: new Uint8Array(0),
addedLines: undefined,
removedLines: undefined,
});
const edits = await db.getFileEdits(['tc-1']);
assert.strictEqual(edits.length, 1);
assert.strictEqual(edits[0].turnId, 'auto-turn');
});
});
// ---- Turns ----------------------------------------------------------
suite('turns', () => {
test('createTurn is idempotent', async () => {
const db = disposables.add(await SessionDatabase.open(dbPath()));
await db.createTurn('turn-1');
await db.createTurn('turn-1'); // should not throw
});
test('deleteTurn cascades to file edits', async () => {
const db = disposables.add(await SessionDatabase.open(dbPath()));
await db.createTurn('turn-1');
await db.storeFileEdit({
turnId: 'turn-1',
toolCallId: 'tc-1',
filePath: '/workspace/a.ts',
beforeContent: new TextEncoder().encode('before'),
afterContent: new TextEncoder().encode('after'),
addedLines: undefined,
removedLines: undefined,
});
// Edits exist
assert.strictEqual((await db.getFileEdits(['tc-1'])).length, 1);
// Delete the turn — edits should be gone
await db.deleteTurn('turn-1');
assert.deepStrictEqual(await db.getFileEdits(['tc-1']), []);
});
test('deleteTurn only removes its own edits', async () => {
const db = disposables.add(await SessionDatabase.open(dbPath()));
await db.createTurn('turn-1');
await db.createTurn('turn-2');
await db.storeFileEdit({
turnId: 'turn-1',
toolCallId: 'tc-1',
filePath: '/workspace/a.ts',
beforeContent: new Uint8Array(0),
afterContent: new TextEncoder().encode('a'),
addedLines: undefined,
removedLines: undefined,
});
await db.storeFileEdit({
turnId: 'turn-2',
toolCallId: 'tc-2',
filePath: '/workspace/b.ts',
beforeContent: new Uint8Array(0),
afterContent: new TextEncoder().encode('b'),
addedLines: undefined,
removedLines: undefined,
});
await db.deleteTurn('turn-1');
assert.deepStrictEqual(await db.getFileEdits(['tc-1']), []);
assert.strictEqual((await db.getFileEdits(['tc-2'])).length, 1);
});
test('deleteTurn is a no-op for unknown turn', async () => {
const db = disposables.add(await SessionDatabase.open(dbPath()));
await db.deleteTurn('nonexistent'); // should not throw
});
});
// ---- Dispose --------------------------------------------------------
suite('dispose', () => {
test('methods throw after dispose', async () => {
const db = await SessionDatabase.open(dbPath());
db.dispose();
await assert.rejects(
() => db.createTurn('turn-1'),
/disposed/,
);
});
test('double dispose is safe', async () => {
const db = await SessionDatabase.open(dbPath());
db.dispose();
db.dispose(); // should not throw
});
});
// ---- Lazy open ------------------------------------------------------
suite('lazy open', () => {
test('constructor does not open the database', () => {
// Should not throw even if path does not exist yet
const db = new SessionDatabase(join(testDir, 'lazy', 'session.db'));
disposables.add(db);
// No error — the database is not opened until first use
});
test('first async call opens and migrates the database', async () => {
const db = disposables.add(new SessionDatabase(dbPath()));
// Database file may not exist yet — first call triggers open
await db.createTurn('turn-1');
const edits = await db.getFileEdits(['nonexistent']);
assert.deepStrictEqual(edits, []);
});
test('multiple concurrent calls share the same open promise', async () => {
const db = disposables.add(new SessionDatabase(dbPath()));
// Fire multiple calls concurrently — all should succeed
await Promise.all([
db.createTurn('turn-1'),
db.createTurn('turn-2'),
db.getFileEdits([]),
]);
});
test('dispose during open rejects subsequent calls', async () => {
const db = new SessionDatabase(dbPath());
db.dispose();
await assert.rejects(() => db.createTurn('turn-1'), /disposed/);
});
});
});

View File

@@ -121,7 +121,7 @@ suite('SessionStateManager', () => {
assert.deepStrictEqual(envelopes[0].origin, origin);
});
test('removeSession clears state and emits notification', () => {
test('removeSession clears state without notification', () => {
manager.createSession(makeSessionSummary());
const notifications: INotification[] = [];
@@ -129,6 +129,19 @@ suite('SessionStateManager', () => {
manager.removeSession(sessionUri);
assert.strictEqual(manager.getSessionState(sessionUri), undefined);
assert.strictEqual(manager.getSnapshot(sessionUri), undefined);
assert.strictEqual(notifications.length, 0);
});
test('deleteSession clears state and emits notification', () => {
manager.createSession(makeSessionSummary());
const notifications: INotification[] = [];
disposables.add(manager.onDidEmitNotification(n => notifications.push(n)));
manager.deleteSession(sessionUri);
assert.strictEqual(manager.getSessionState(sessionUri), undefined);
assert.strictEqual(manager.getSnapshot(sessionUri), undefined);
assert.strictEqual(notifications.length, 1);

View File

@@ -786,7 +786,7 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio
try {
const data = await this._fileService.readFile(contentSource);
afterSnapshot = data.value.toString();
} catch {
} catch (_e) {
afterSnapshot = '';
}
} else {