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
27 changes: 20 additions & 7 deletions src/vs/platform/agentHost/node/agentService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -931,6 +931,16 @@ export class AgentService extends Disposable implements IAgentService {
if (initialCustomizations && initialCustomizations.length > 0) {
state.customizations = [...initialCustomizations];
}

// Refine the placeholder title into one generated from the imported
// conversation, mirroring forks. Imports seed pre-existing turns, so
// the normal first-message title generation never fires; without this
// the session would keep showing the raw first-message clip while
// sibling sessions show clean generated titles — making imports look
// like a different kind of session.
if (importedTurns.length > 0) {
this._sideEffects.generateForkedTitle(summary.resource, undefined, importedTurns, importedTitle);
}
} else {
// Provisional sessions defer the `sessionAdded` notification and
// the `SessionReady` lifecycle transition until the agent fires
Expand Down Expand Up @@ -1199,19 +1209,22 @@ export class AgentService extends Disposable implements IAgentService {
}

/**
* Derives a title for an imported session from its first user turn (imports
* seed pre-existing turns, so the normal first-message title generation
* never fires). Falls back to a generic label for an empty import.
* Derives a placeholder title for an imported session from its first user
* turn (imports seed pre-existing turns, so the normal first-message title
* generation never fires). Deliberately unprefixed: an imported session is a
* continuation of the source chat, not a distinct kind of session, so it
* should read like any other. The placeholder is later refined into a
* generated title (see the `importConversation` branch in `createSession`),
* but a neutral non-empty fallback is kept so the session still reads like a
* normal chat when generation is unavailable or fails.
*/
private _buildImportedTitle(turns: readonly Turn[]): string {
const importedPrefix = localize('agentHost.importedTitlePrefix', "Imported: ");
const firstText = turns.find(t => t.message?.text?.trim())?.message.text.trim();
if (!firstText) {
return localize('agentHost.importedSessionFallback', "Imported Session");
return localize('agentHost.importedSessionFallback', "New Session");
}
const MAX = 60;
const clipped = firstText.length > MAX ? `${firstText.slice(0, MAX)}...` : firstText;
return `${importedPrefix}${clipped}`;
return firstText.length > MAX ? `${firstText.slice(0, MAX)}...` : firstText;
}

private _buildInitialSummary(provider: IAgent, session: URI, config: IAgentCreateSessionConfig | undefined, created: { project?: { uri: URI; displayName: string }; workingDirectory?: URI }, title: string): SessionSummary {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -595,6 +595,14 @@ export class CreateRemoteAgentJobAction {
type: continuationTargetType,
displayName: continuationTarget.displayName,
position: isSidebar ? ChatSessionPosition.Sidebar : ChatSessionPosition.Editor,
// Replace the source chat editor in place so switching harness
// feels like the same chat continues rather than opening a new
// tab. The source (local) session stays in chat history and is
// recoverable. The sidebar path already swaps in place via
// `loadSession`, so it needs no replacement. Pass the source
// resource (not a bare flag) so the correct editor is resolved
// at replace time even if the active editor changed meanwhile.
replaceEditorForResource: isSidebar ? undefined : sessionResource,
},
{
prompt: handoffPrompt,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1175,84 +1175,102 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC
return {};
}

const resolvedSession = this._resolveSessionUri(request.sessionResource);
const sessionKey = resolvedSession.toString();

// The chat-input picker may have pre-created a provisional session
// against this resource (`IAgentHostUntitledProvisionalSessionService.getOrCreate`).
// In that case the agent already has the session + the user's chip
// selections in `state.config.values`; ensure we hold a refcounted
// subscription on it so the rest of the handler observes those.
const provisionalBackend = this._provisionalService.get(request.sessionResource);
if (provisionalBackend) {
this._ensureSessionSubscription(sessionKey);
}

// The sessions provider may have eagerly created this session at
// folder-pick time and is holding the connection-level subscription
// open with hydrated state. Use the unmanaged accessor to peek
// without taking a fresh subscription, which would trigger a
// duplicate snapshot fetch and (in tests) unrelated mock behaviour.
const existingState = await this._readEagerlyCreatedSessionState(resolvedSession, cancellationToken);

if (!existingState) {
// Eager-create did not produce server-side state (e.g. no
// sessions provider involved, agent host not connected at
// folder-pick time, or this session was created via a legacy/
// test path). Fall back to the original create-then-subscribe
// flow.
//
// If a conversation was imported ("Continue in…") into this
// session, seed it as real editable history at creation time.
const imported = this._importConversationStore.take(request.sessionResource);
const model = imported?.model ?? this._createModelSelection(request.userSelectedModelId, request.modelConfiguration);
await this._createAndSubscribe(request.sessionResource, model, undefined, request.agentHostSessionConfig, imported ? { turns: imported.turns, model: imported.model } : undefined);
} else {
// Eager-created session: take a refcounted subscription so the
// handler observes state changes for the duration of the chat
// session, then wire up the per-turn machinery that
// `_createAndSubscribe` would normally set up.
this._ensureSessionSubscription(sessionKey);
this._setChatURI(request.sessionResource, this._resolveChatUriFromState(request.sessionResource, existingState));
this._ensurePendingMessageSubscription(request.sessionResource, resolvedSession);
this._watchForServerInitiatedTurns(resolvedSession, request.sessionResource);

// In the Agents window, the sessions provider supplies per-request
// config via `request.agentHostSessionConfig` (e.g. the user's
// permission level). Push it to the agent so its provisional record
// materializes with those values. Workbench defaults (`isolation`,
// `autoApprove`) are seeded upstream at provisional `createSession`
// time, so we don't need to merge them here. Picker selections
// already live in `existingState.config?.values` and don't need to
// be re-dispatched.
if (request.agentHostSessionConfig && Object.keys(request.agentHostSessionConfig).length > 0) {
this._dispatchAction(resolvedSession, {
type: ActionType.SessionConfigChanged,
config: request.agentHostSessionConfig,
});
// Creating/resuming an agent-host session can take several seconds on
// first use (CLI spawn, git worktree, plugin snapshot) — work done below
// before any turn progress is emitted. Show a shimmering status only if the
// turn is slow to start, cancelled as soon as real progress streams, so
// established sessions' fast turns never flash it.
const preparingStatus = disposableTimeout(() => {
progress([{ kind: 'progressMessage', content: new MarkdownString(localize('agentHost.preparingSession', "Preparing session…")), shimmer: true }]);
}, 500);
Comment thread
vijayupadya marked this conversation as resolved.

try {
const resolvedSession = this._resolveSessionUri(request.sessionResource);
const sessionKey = resolvedSession.toString();

// The chat-input picker may have pre-created a provisional session
// against this resource (`IAgentHostUntitledProvisionalSessionService.getOrCreate`).
// In that case the agent already has the session + the user's chip
// selections in `state.config.values`; ensure we hold a refcounted
// subscription on it so the rest of the handler observes those.
const provisionalBackend = this._provisionalService.get(request.sessionResource);
if (provisionalBackend) {
this._ensureSessionSubscription(sessionKey);
}
}

// Measure turn timings so the core `interactiveSessionProviderInvoked`
// telemetry event is populated for agent-host providers.
const stopWatch = StopWatch.create(false);
let firstProgress: number | undefined;
const measuredProgress = (parts: IChatProgress[]) => {
if (firstProgress === undefined && parts.some(isFirstVisibleProgressPart)) {
firstProgress = stopWatch.elapsed();
// The sessions provider may have eagerly created this session at
// folder-pick time and is holding the connection-level subscription
// open with hydrated state. Use the unmanaged accessor to peek
// without taking a fresh subscription, which would trigger a
// duplicate snapshot fetch and (in tests) unrelated mock behaviour.
const existingState = await this._readEagerlyCreatedSessionState(resolvedSession, cancellationToken);

if (!existingState) {
// Eager-create did not produce server-side state (e.g. no
// sessions provider involved, agent host not connected at
// folder-pick time, or this session was created via a legacy/
// test path). Fall back to the original create-then-subscribe
// flow.
//
// If a conversation was imported ("Continue in…") into this
// session, seed it as real editable history at creation time.
const imported = this._importConversationStore.take(request.sessionResource);
const model = imported?.model ?? this._createModelSelection(request.userSelectedModelId, request.modelConfiguration);
await this._createAndSubscribe(request.sessionResource, model, undefined, request.agentHostSessionConfig, imported ? { turns: imported.turns, model: imported.model } : undefined);
} else {
// Eager-created session: take a refcounted subscription so the
// handler observes state changes for the duration of the chat
// session, then wire up the per-turn machinery that
// `_createAndSubscribe` would normally set up.
this._ensureSessionSubscription(sessionKey);
this._setChatURI(request.sessionResource, this._resolveChatUriFromState(request.sessionResource, existingState));
this._ensurePendingMessageSubscription(request.sessionResource, resolvedSession);
this._watchForServerInitiatedTurns(resolvedSession, request.sessionResource);

// In the Agents window, the sessions provider supplies per-request
// config via `request.agentHostSessionConfig` (e.g. the user's
// permission level). Push it to the agent so its provisional record
// materializes with those values. Workbench defaults (`isolation`,
// `autoApprove`) are seeded upstream at provisional `createSession`
// time, so we don't need to merge them here. Picker selections
// already live in `existingState.config?.values` and don't need to
// be re-dispatched.
if (request.agentHostSessionConfig && Object.keys(request.agentHostSessionConfig).length > 0) {
this._dispatchAction(resolvedSession, {
type: ActionType.SessionConfigChanged,
config: request.agentHostSessionConfig,
});
}
}
progress(parts);
};

const completedTurn = await this._handleTurn(resolvedSession, request, measuredProgress, cancellationToken);
const details = this._getTurnResponseDetails(request.sessionResource, resolvedSession, completedTurn);
const errorDetails = this._getTurnErrorDetails(completedTurn);
// Measure turn timings so the core `interactiveSessionProviderInvoked`
// telemetry event is populated for agent-host providers.
const stopWatch = StopWatch.create(false);
let firstProgress: number | undefined;
const measuredProgress = (parts: IChatProgress[]) => {
// Real progress has started — cancel the pending "preparing" status.
preparingStatus.dispose();
if (firstProgress === undefined && parts.some(isFirstVisibleProgressPart)) {
firstProgress = stopWatch.elapsed();
}
progress(parts);
};

return {
timings: { firstProgress, totalElapsed: stopWatch.elapsed() },
...(details ? { details } : {}),
...(errorDetails ? { errorDetails } : {}),
};
const completedTurn = await this._handleTurn(resolvedSession, request, measuredProgress, cancellationToken);
const details = this._getTurnResponseDetails(request.sessionResource, resolvedSession, completedTurn);
const errorDetails = this._getTurnErrorDetails(completedTurn);

return {
timings: { firstProgress, totalElapsed: stopWatch.elapsed() },
...(details ? { details } : {}),
...(errorDetails ? { errorDetails } : {}),
};
} finally {
// Always cancel the pending "preparing" status — including when an
// await above (state read, create/subscribe, turn handling) rejects —
// so a stale status can never fire after the invocation has ended.
preparingStatus.dispose();
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,27 +38,41 @@ function stringifyToolInput(rawInput: unknown): string {
* text) as a markdown link so it survives migration instead of leaving a gap
* where the chip used to be. Falls back to plain label text when no URI is
* available.
*
* The source chip shows a short label — a file's basename or a symbol's name —
* never a workspace-relative path. Some inline references carry that path in
* their `name`, so a path-like label is collapsed to the URI's basename to
* avoid leaking the tree into the imported transcript.
*/
function inlineReferenceToMarkdown(reference: IChatContentInlineReference['inlineReference'], name: string | undefined): string {
let uri: URI | undefined;
let label = name;
let isSymbol = false;
if (URI.isUri(reference)) {
uri = reference;
} else {
// `Location` carries the URI directly; `IWorkspaceSymbol` nests it under
// `location` and supplies its own display name.
const location = reference as { uri?: URI; location?: { uri?: URI } };
const location = reference as { uri?: URI; location?: { uri?: URI }; name?: string };
if (URI.isUri(location.uri)) {
uri = location.uri;
} else if (URI.isUri(location.location?.uri)) {
uri = location.location.uri;
label = label ?? (reference as { name?: string }).name;
label = label ?? location.name;
isSymbol = true;
}
}
if (!uri) {
return label ?? '';
}
return `[${label ?? basename(uri)}](${uri.toString()})`;
// A file reference with a missing or path-like label (some carry the
// workspace-relative path as their name) collapses to the basename, matching
// the source chip. A symbol's name is preserved verbatim — it may legitimately
// contain a separator (e.g. C++ `operator/`) and must not be treated as a path.
if (!label || (!isSymbol && /[\\/]/.test(label))) {
label = basename(uri);
}
return `[${label}](${uri.toString()})`;
}

/**
Expand Down
Loading
Loading