Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion src/vs/platform/agentHost/common/sessionDataService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { URI } from '../../../base/common/uri.js';
import { createDecorator } from '../../instantiation/common/instantiation.js';
import { Event } from '../../../base/common/event.js';
import { getLargeFileConfirmationLimit } from '../../files/common/files.js';
import type { FileEditKind, Message } from './state/sessionState.js';
import type { FileEditKind, MarkdownResponsePart, Message, ReasoningResponsePart, ResponsePartKind } from './state/sessionState.js';

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

Expand Down Expand Up @@ -98,6 +98,9 @@ export interface IReviewedFileRecord {

// ---- Session database ---------------------------------------------------

/** Ordered response text with stable reasoning-run identities and references to provider-owned tool results. */
export type StoredAssistantResponsePart = MarkdownResponsePart | ReasoningResponsePart | { readonly kind: ResponsePartKind.ToolCall; readonly toolCallId: string };

/** A host-persisted turn that is absent from the provider transcript. */
export interface IPersistedTurnRecord {
/** Whether this is host-local or a provider turn that failed before persistence. */
Expand Down Expand Up @@ -266,6 +269,12 @@ export interface ISessionDatabase extends IDisposable {
*/
getTurnUsages(): Promise<Map<string, string>>;

/** Records a response's observed part ordering and reasoning timings with the owning turn's pruning lifecycle. */
setAssistantResponseParts(turnId: string, responseId: string, parts: readonly StoredAssistantResponsePart[]): Promise<void>;

/** Reads recorded parts keyed by durable provider response id, after all previously submitted writes. */
getAssistantResponseParts(): Promise<Map<string, readonly StoredAssistantResponsePart[]>>;

/**
* Persists the JSON-serialized delegation metadata for an agent-authored turn.
* Idempotent — last writer wins per turn.
Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1 @@
5872a476
d7a5579b
Original file line number Diff line number Diff line change
Expand Up @@ -678,7 +678,8 @@ export interface ChatUsageAction {
* Reasoning/thinking text from the model, appended to a specific reasoning response part.
*
* The server MUST first emit a `chat/responsePart` to create the target
* reasoning part, then use this action to append text to it.
* reasoning part, then use this action to append text or record its timing.
* An empty `content` updates only the supplied timing fields.
*
* @category Chat Actions
* @version 1
Expand All @@ -691,6 +692,10 @@ export interface ChatReasoningAction {
partId: string;
/** Reasoning text chunk */
content: string;
/** Producer-recorded ISO 8601 start of the reasoning interval; omitted to preserve the existing value. */
startedAt?: string;
/** Final elapsed reasoning time in non-negative integer milliseconds, measured by the producer's clock; omitted to preserve the existing value. Zero is a recorded duration, not unknown. */
duration?: number;
/**
* Additional provider-specific metadata for this action.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -763,7 +763,12 @@ export function chatReducer(state: ChatState, action: ChatAction, log?: (msg: st
case ActionType.ChatReasoning:
return updateResponsePart(state, action.turnId, action.partId, part => {
if (part.kind === ResponsePartKind.Reasoning) {
return { ...part, content: part.content + action.content };
return {
...part,
content: part.content + action.content,
...(action.startedAt !== undefined ? { startedAt: action.startedAt } : {}),
...(action.duration !== undefined ? { duration: action.duration } : {}),
};
}
return part;
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1131,6 +1131,10 @@ export interface ReasoningResponsePart {
id: string;
/** Accumulated reasoning text */
content: string;
/** Producer-recorded ISO 8601 start of this reasoning interval; absent when unknown. */
startedAt?: string;
/** Final elapsed reasoning time in non-negative integer milliseconds, measured by the producer's clock; absent while unfinished or unknown. Clients MUST NOT derive it from their own clock. */
duration?: number;
}

/**
Expand Down
8 changes: 8 additions & 0 deletions src/vs/platform/agentHost/node/compatibleSessionDatabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,14 @@ export class CompatibleSessionDatabase implements ISessionDatabase {
return result;
}

async setAssistantResponseParts(...args: Parameters<ISessionDatabase['setAssistantResponseParts']>): ReturnType<ISessionDatabase['setAssistantResponseParts']> {
return this.raw.setAssistantResponseParts(...args);
}

async getAssistantResponseParts(): ReturnType<ISessionDatabase['getAssistantResponseParts']> {
return this.raw.getAssistantResponseParts();
}

async getTurnDelegations(...[]: Parameters<ISessionDatabase['getTurnDelegations']>): ReturnType<ISessionDatabase['getTurnDelegations']> {
const result = await this.raw.getTurnDelegations();
return new Map([...result].map(([key, value]) => [key, this.identity.metadata('agentHost.turnDelegation', value, 'runtime')!]));
Expand Down
15 changes: 12 additions & 3 deletions src/vs/platform/agentHost/node/copilot/buildSessionEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import type { JsonValue, SessionEvent } from '@github/copilot-sdk';
import { isObject } from '../../../../base/common/types.js';
import { generateUuid, isUUID } from '../../../../base/common/uuid.js';
import { getInlineToolInput, ResponsePartKind, ToolCallStatus, ToolResultContentType, TurnState, type ToolCallCompletedState, type ToolResultContent, type ToolResultSubagentContent, type Turn } from '../../common/state/sessionState.js';
import { getInlineToolInput, ResponsePartKind, ToolCallStatus, ToolResultContentType, TurnState, type MarkdownResponsePart, type ReasoningResponsePart, type ToolCallCompletedState, type ToolResultContent, type ToolResultSubagentContent, type Turn } from '../../common/state/sessionState.js';

/**
* Default schema version stamped on the synthesized `session.start` event.
Expand Down Expand Up @@ -44,6 +44,8 @@ export interface IBuildSessionEventsOptions {
readonly schemaVersion?: number;
/** Base time for the synthesized (monotonically increasing) event timestamps. Defaults to now. */
readonly startTime?: Date;
/** Associates original response parts with their synthesized user-turn and assistant-event IDs. */
readonly onAssistantMessage?: (turnId: string, responseId: string, parts: readonly (MarkdownResponsePart | ReasoningResponsePart)[]) => void;
}

/**
Expand Down Expand Up @@ -167,8 +169,9 @@ export function buildSessionEventsFromTurns(turns: readonly Turn[], options: IBu
// UUID (the runtime rejects non-UUID event ids) so the reconstructed turn
// keeps the id the SDK's fork/truncate RPCs address and a caller can seed
// a matching protocol turn; otherwise mint a fresh UUID.
const turnId = isUUID(turn.id) ? turn.id : generateUuid();
push({
id: isUUID(turn.id) ? turn.id : generateUuid(),
id: turnId,
parentId,
timestamp: nextTimestamp(),
type: 'user.message',
Expand All @@ -180,12 +183,14 @@ export function buildSessionEventsFromTurns(turns: readonly Turn[], options: IBu

let markdown = '';
let reasoning = '';
let parts: (MarkdownResponsePart | ReasoningResponsePart)[] = [];
const flushAssistantMessage = (): void => {
if (!markdown && !reasoning) {
return;
}
const responseId = generateUuid();
push({
id: generateUuid(),
id: responseId,
parentId,
timestamp: nextTimestamp(),
type: 'assistant.message',
Expand All @@ -196,8 +201,10 @@ export function buildSessionEventsFromTurns(turns: readonly Turn[], options: IBu
...(options.model ? { model: options.model } : {}),
},
});
options.onAssistantMessage?.(turnId, responseId, parts);
markdown = '';
reasoning = '';
parts = [];
};

for (const part of turn.responseParts) {
Expand All @@ -210,11 +217,13 @@ export function buildSessionEventsFromTurns(turns: readonly Turn[], options: IBu
flushAssistantMessage();
}
markdown += part.content;
parts.push({ ...part });
} else if (part.kind === ResponsePartKind.Reasoning) {
if (markdown) {
flushAssistantMessage();
}
reasoning += part.content;
parts.push({ ...part });
} else if (part.kind === ResponsePartKind.ToolCall && part.toolCall.status === ToolCallStatus.Completed) {
// Flush accumulated assistant text before the tool call so the
// reconstructed part order matches the original interleaving.
Expand Down
21 changes: 19 additions & 2 deletions src/vs/platform/agentHost/node/copilot/copilotAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, JsonRpcErrorCodes, ProtocolError
import { ICopilotConfigSlashCommandState } from '../../common/copilotConfigSlashCommands.js';
import { ICopilotHomeService } from '../../../environment/common/copilotHome.js';
import { CopilotCustomizationInstallations } from './customizations/copilotCustomizationInstallations.js';
import { ISessionDataService, SESSION_DB_FILENAME, type ISessionDatabase } from '../../common/sessionDataService.js';
import { ISessionDataService, SESSION_DB_FILENAME, type ISessionDatabase, type StoredAssistantResponsePart } from '../../common/sessionDataService.js';
import { IAgentHostProxyResolver } from '../agentHostProxyResolver.js';
import { writeUserDataCertificatesFile } from '../../../request/node/certificates.js';
import { MODEL_REFRESH_BASE_DELAY_MS, MODEL_REFRESH_MAX_ATTEMPTS, MODEL_REFRESH_MAX_DELAY_MS, modelRefreshBackoff } from '../shared/modelRefreshRetry.js';
Expand All @@ -79,7 +79,7 @@ import { ProtectedResourceMetadata, type AgentCustomization, type AgentSelection
import { ActionType, AuthRequiredReason, type AuthRequiredParams, type SessionAction } from '../../common/state/sessionActions.js';
import { areAdditionalWorkingDirectoriesEqual } from '../../common/state/sessionWorkingDirectories.js';
import { getRepositoryRootFromWorktree } from '../../common/worktreePaths.js';
import { CustomizationLoadStatus, CustomizationType, ChatInputResponseKind, customizationId, buildChatUri, AH_META_WORKSPACELESS_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, AH_META_EHCLI_LAST_TURN_DB_KEY, AH_META_IS_READ_DB_KEY, SessionStatus, withSessionEhcliAdoptable, withSessionStatusFlag, withSessionWorkspaceless, type ChildCustomization, type ClientPluginCustomization, type Customization, type DirectoryCustomization, type ISessionFolderPickerDecision, type MessageAttachment, type PendingMessage, type PluginCustomization, type PolicyState, type ChatInputAnswer, type ToolCallResult, type Turn, type UsageInfo } from '../../common/state/sessionState.js';
import { CustomizationLoadStatus, CustomizationType, ChatInputResponseKind, customizationId, buildChatUri, AH_META_WORKSPACELESS_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, AH_META_EHCLI_LAST_TURN_DB_KEY, AH_META_IS_READ_DB_KEY, ResponsePartKind, SessionStatus, withSessionEhcliAdoptable, withSessionStatusFlag, withSessionWorkspaceless, type ChildCustomization, type ClientPluginCustomization, type Customization, type DirectoryCustomization, type ISessionFolderPickerDecision, type MessageAttachment, type PendingMessage, type PluginCustomization, type PolicyState, type ChatInputAnswer, type ToolCallResult, type Turn, type UsageInfo } from '../../common/state/sessionState.js';
import { CopilotChatDiscovery, ICopilotChatDiscoveryScan } from './copilotChatDiscovery.js';
import { getByokLmAgentModelId, resolveByokLmEnablement } from '../../common/agentHostByokLm.js';
import { isCustomizationEnabled } from '../../common/customizationEnablement.js';
Expand Down Expand Up @@ -6031,13 +6031,29 @@ export class CopilotAgent extends Disposable implements IAgent {
// so the git probe and file I/O overlap on the session-creation path.
const projectPromise = projectFromCopilotContext({ cwd: workingDirectory.fsPath }, this._gitService);
const eventsPath = resourceJoinPath(this._copilotHomeService.home, 'session-state', sessionId, 'events.jsonl').fsPath;
const responses: { turnId: string; responseId: string; parts: readonly StoredAssistantResponsePart[] }[] = [];
const jsonl = buildSessionEventLogFromTurns(importConfig.turns, {
sessionId,
workingDirectory: workingDirectory.fsPath,
model: model?.id,
onAssistantMessage: (turnId, responseId, parts) => {
if (parts.some(part => part.kind === ResponsePartKind.Reasoning)) {
responses.push({ turnId, responseId, parts });
}
},
});
await fs.mkdir(dirname(eventsPath), { recursive: true });
await fs.writeFile(eventsPath, jsonl, 'utf8');
if (responses.length) {
const reference = this._sessionDataService.openDatabase(this._resolveChatStorageScope(chat));
try {
for (const response of responses) {
await reference.object.setAssistantResponseParts(response.turnId, response.responseId, response.parts);
}
} finally {
reference.dispose();
}
}

// Persist metadata before resume so `_resumeSession` can resolve the
// working directory and model.
Expand Down Expand Up @@ -7654,6 +7670,7 @@ export class CopilotAgent extends Disposable implements IAgent {
try {
const backing = this._chatBackings.get(context.chatKey);
const result = await mapSessionEvents(context.resource, reference.object, events, context.chat, context.ownerSession, {
logService: this._logService,
workingDirectory: metadata.workingDirectory,
clientToolNames: new Set(this._activeClients.get(context.ownerSession)?.toolSet.merged().map(tool => tool.name) ?? []),
model: backing?.model ?? metadata.model,
Expand Down
Loading
Loading