Agent host: generate PR title and description from session conversation (#322954)

* agent host: generate PR title and description from session conversation

The local agent host Create PR / Create Draft PR operation previously
derived the title and body from the branch name only. It now asks the
utility model for a title and description, feeding it the main session
conversation (markdown text of requests/responses only — tool calls,
subagents, and reasoning are excluded and the text is char-bounded) plus
a changed-file summary. Falls back to the branch-name title/body when no
Copilot token is available or generation fails, so PR creation never
fails because the model is unavailable.

The conversation-to-text logic is extracted from the session title
controller into a shared, configurable helper
(agentHostConversationContext) that truncates only the conversation and
preserves any framing in full.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* agent host: fix PR title generation typecheck and diff summary perf

Use ISessionWithDefaultChat (which exposes `turns`) for the PR handler's
session state so the conversation context can be built, fixing the
tsgo typecheck failure. Also track the running length in
_summarizeDiffsForPrompt instead of re-joining the lines each iteration.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* agent host: use const for parsed PR title (eslint prefer-const)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Benjamin Christopher Simmonds
2026-06-25 19:17:40 +02:00
committed by GitHub
parent 4c2fab7133
commit 9012e0e98f
4 changed files with 416 additions and 69 deletions
@@ -0,0 +1,98 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { ResponsePartKind, type ResponsePart, type Turn } from './state/sessionState.js';
/**
* Options for {@link buildConversationContext}.
*/
export interface IConversationContextOptions {
/**
* Soft upper bound, in characters, for the conversation portion of the
* produced context string. When the conversation exceeds this budget its
* middle is removed (marked with `...`) via {@link truncateMiddle}. The
* optional {@link framing} is always preserved in full and does not count
* against this budget.
*/
readonly maxChars: number;
/**
* Optional framing text prepended to the conversation (e.g. a note that the
* conversation was branched from an earlier chat). Always preserved in full
* — only the conversation is truncated to {@link maxChars}.
*/
readonly framing?: string;
}
/**
* Concatenates the normal textual (markdown) response parts of a turn into a
* single string. Tool calls, reasoning, content references, and other
* non-markdown parts are intentionally ignored so that only the assistant's
* user-facing prose is included — this keeps utility-model prompts focused and
* free of large tool payloads or subagent traces.
*/
export function renderResponseMarkdown(parts: readonly ResponsePart[]): string {
const segments: string[] = [];
for (const part of parts) {
if (part.kind === ResponsePartKind.Markdown) {
const text = part.content.trim();
if (text) {
segments.push(text);
}
}
}
return segments.join('\n\n');
}
/**
* Builds a plain-text conversation context string from the given turns by
* concatenating each turn's user request and the assistant's textual
* (markdown) response. Only normal text response parts are considered — tool
* calls, reasoning, subagent traces, and other parts are ignored (see
* {@link renderResponseMarkdown}). The conversation is middle-truncated to
* {@link IConversationContextOptions.maxChars} to bound model cost; any
* {@link IConversationContextOptions.framing} is prepended afterwards and is
* always preserved in full.
*
* @returns the context string, or `undefined` when no turn carries any text
* worth including.
*/
export function buildConversationContext(turns: readonly Turn[], options: IConversationContextOptions): string | undefined {
const blocks: string[] = [];
for (const turn of turns) {
const userText = turn.message.text.trim();
const responseText = renderResponseMarkdown(turn.responseParts);
if (!userText && !responseText) {
continue;
}
blocks.push(responseText
? `User request:\n${userText}\n\nAgent response:\n${responseText}`
: `User request:\n${userText}`);
}
if (blocks.length === 0) {
return undefined;
}
const conversation = blocks.join('\n\n---\n\n');
const truncatedConversation = conversation.length > options.maxChars ? truncateMiddle(conversation, options.maxChars) : conversation;
return `${options.framing ?? ''}${truncatedConversation}`;
}
/**
* Truncates `text` to at most `maxChars` characters by removing the middle and
* inserting a `...` marker, preserving the start and end.
*/
export function truncateMiddle(text: string, maxChars: number): string {
if (text.length <= maxChars) {
return text;
}
const marker = '\n...\n';
if (maxChars <= marker.length) {
return text.slice(0, maxChars);
}
const keep = maxChars - marker.length;
const head = Math.ceil(keep / 2);
const tail = keep - head;
return `${text.slice(0, head)}${marker}${text.slice(text.length - tail)}`;
}
@@ -6,15 +6,31 @@
import { CancellationToken } from '../../../base/common/cancellation.js';
import { URI } from '../../../base/common/uri.js';
import { localize } from '../../../nls.js';
import { GITHUB_REPO_PROTECTED_RESOURCE, IAgentService } from '../common/agentService.js';
import { GITHUB_COPILOT_PROTECTED_RESOURCE, GITHUB_REPO_PROTECTED_RESOURCE, IAgentService } from '../common/agentService.js';
import { parseChangesetUri } from '../common/changesetUri.js';
import { AHP_AUTH_REQUIRED, AHP_SESSION_NOT_FOUND, JsonRpcErrorCodes, ProtocolError } from '../common/state/sessionProtocol.js';
import { readSessionGitHubState, readSessionGitState, type ChangesetOperationFollowUp, type SessionState } from '../common/state/sessionState.js';
import { readSessionGitHubState, readSessionGitState, type ChangesetOperationFollowUp, type ISessionFileDiff, type ISessionWithDefaultChat } from '../common/state/sessionState.js';
import { ILogService } from '../../log/common/log.js';
import { IAgentHostGitService } from '../common/agentHostGitService.js';
import { type IChangesetOperationHandler } from '../common/agentHostChangesetOperationService.js';
import { IAgentHostOctoKitService } from './shared/agentHostOctoKitService.js';
import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js';
import { ICopilotApiService, type ICopilotUtilityChatMessage } from './shared/copilotApiService.js';
import { buildConversationContext } from '../common/agentHostConversationContext.js';
/**
* Soft upper bound, in characters, for the conversation context fed to the
* utility model when generating a PR title and description. Sized to stay
* within the small model's context window while leaving room for the changed
* file summary and prompt scaffolding.
*/
const MAX_PR_CONVERSATION_CONTEXT_CHARS = 12_000;
/**
* Soft upper bound, in characters, for the changed-file summary fed to the
* utility model when generating a PR title and description.
*/
const MAX_PR_CHANGE_SUMMARY_CHARS = 4_000;
export interface PullRequestCreatedEvent {
readonly sessionKey: string;
@@ -47,11 +63,12 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation
constructor(
private readonly _draft: boolean,
private readonly _getSessionState: (sessionKey: string) => SessionState | undefined,
private readonly _getSessionState: (sessionKey: string) => ISessionWithDefaultChat | undefined,
private readonly _onPullRequestCreated: (event: PullRequestCreatedEvent) => void,
@IAgentService private readonly _agentService: IAgentService,
@IAgentHostGitService private readonly _gitService: IAgentHostGitService,
@IAgentHostOctoKitService private readonly _octoKitService: IAgentHostOctoKitService,
@ICopilotApiService private readonly _copilotApiService: ICopilotApiService,
@ILogService private readonly _logService: ILogService,
) { }
@@ -153,9 +170,6 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation
}
this._throwIfCancelled(token);
const title = this._formatTitle(branchName);
const body = this._formatBody(branchName, base);
const existing = await this._octoKitService.findPullRequestByHeadBranch(gitHubState.owner, gitHubState.repo, branchName, authToken, signal);
if (existing) {
this._throwIfCancelled(token);
@@ -164,6 +178,11 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation
}
this._throwIfCancelled(token);
const generated = await this._generateTitleAndDescription(sessionState, branchName, base, branchChanges, signal, token);
this._throwIfCancelled(token);
const title = generated?.title ?? this._formatTitle(branchName);
const body = generated?.description ?? this._formatBody(branchName, base);
this._logService.info(`[AgentHostPullRequestOperationHandler] Creating ${this._draft ? 'draft ' : ''}PR ${gitHubState.owner}/${gitHubState.repo} ${branchName} -> ${base}`);
let created: { readonly url: string; readonly number: number };
try {
@@ -228,6 +247,151 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation
return localize('agentHost.changeset.pr.body', "Created from `{0}` targeting `{1}`.", branchName, baseBranchName);
}
/**
* Best-effort generation of a PR title and description using the utility
* model. The model is given the main session conversation (only the
* markdown text of user requests and agent responses — tool calls,
* subagents, and reasoning are excluded and the text is character-bounded)
* along with a summary of the changed files. Returns `undefined` when no
* Copilot token is available or generation fails, so the caller can fall
* back to the branch-name based title/description. PR creation must never
* fail just because the model is unavailable.
*/
private async _generateTitleAndDescription(
sessionState: ISessionWithDefaultChat,
branchName: string,
base: string,
branchChanges: readonly ISessionFileDiff[],
signal: AbortSignal,
token: CancellationToken,
): Promise<{ title: string; description: string } | undefined> {
const copilotToken = this._agentService.getAuthToken({
resource: GITHUB_COPILOT_PROTECTED_RESOURCE.resource,
scopes: GITHUB_COPILOT_PROTECTED_RESOURCE.scopes_supported,
});
if (!copilotToken) {
return undefined;
}
const conversation = buildConversationContext(sessionState.turns, { maxChars: MAX_PR_CONVERSATION_CONTEXT_CHARS });
const changeSummary = this._summarizeDiffsForPrompt(branchChanges);
if (!conversation && !changeSummary) {
return undefined;
}
try {
const raw = await this._copilotApiService.utilityChatCompletion(copilotToken, {
messages: this._buildTitleAndDescriptionPrompt(branchName, base, conversation, changeSummary),
}, { signal });
this._throwIfCancelled(token);
return this._parseTitleAndDescription(raw);
} catch (err) {
if (token.isCancellationRequested) {
return undefined;
}
this._logService.warn(`[AgentHostPullRequestOperationHandler] Failed to generate PR title and description: ${err instanceof Error ? err.message : String(err)}`);
return undefined;
}
}
private _buildTitleAndDescriptionPrompt(branchName: string, base: string, conversation: string | undefined, changeSummary: string): ICopilotUtilityChatMessage[] {
const userSections: string[] = [
`Branch: ${branchName}`,
`Base branch: ${base}`,
];
if (changeSummary) {
userSections.push(`Changed files:\n${changeSummary}`);
}
if (conversation) {
userSections.push(`Conversation (the request that produced these changes):\n${conversation}`);
}
return [
{
role: 'system',
content: [
'You write clear, concise GitHub pull request titles and descriptions.',
'The first line of your reply is the PR title: a short imperative summary under 72 characters, with no "Title:" prefix, no surrounding quotes, and no markdown heading.',
'After the title, add one blank line, then write the PR description in GitHub-flavored markdown.',
'Summarize what changed and why, grounded in the conversation and changed files. Use a short paragraph and/or bullet points.',
'Do not invent changes that are not supported by the provided context, and do not wrap the whole reply in code fences.',
].join(' '),
},
{
role: 'user',
content: userSections.join('\n\n'),
},
];
}
private _summarizeDiffsForPrompt(diffs: readonly ISessionFileDiff[]): string {
const lines: string[] = [];
let length = 0;
for (const diff of diffs) {
const before = diff.before?.uri;
const after = diff.after?.uri;
const path = after ?? before ?? '(unknown)';
let kind = 'Edit';
if (!before && after) {
kind = 'Create';
} else if (before && !after) {
kind = 'Delete';
} else if (before && after && before !== after) {
kind = 'Rename';
}
const line = `- ${kind}: ${this._displayUri(path)} (+${diff.diff?.added ?? 0} -${diff.diff?.removed ?? 0})`;
lines.push(line);
// `+ 1` accounts for the newline that joins this line to the previous one.
length += line.length + (lines.length > 1 ? 1 : 0);
if (length > MAX_PR_CHANGE_SUMMARY_CHARS) {
lines.push('[file list truncated]');
break;
}
}
return lines.join('\n');
}
private _displayUri(uri: string): string {
try {
const parsed = URI.parse(uri);
return parsed.scheme === 'file' ? parsed.fsPath : parsed.path || uri;
} catch {
return uri;
}
}
private _parseTitleAndDescription(raw: string): { title: string; description: string } | undefined {
let text = raw.trim().replace(/\r\n/g, '\n');
const fenced = /^```(?:markdown|md|text)?\s*([\s\S]*?)\s*```$/i.exec(text);
if (fenced) {
text = fenced[1].trim();
}
if (!text) {
return undefined;
}
const lines = text.split('\n');
let i = 0;
while (i < lines.length && lines[i].trim().length === 0) {
i++;
}
if (i >= lines.length) {
return undefined;
}
const title = lines[i].trim()
.replace(/^#+\s*/, '')
.replace(/^title:\s*/i, '')
.trim()
.replace(/^"(?<inner>.+)"$/, (_match, inner) => inner)
.trim();
if (!title) {
return undefined;
}
const description = lines.slice(i + 1).join('\n').trim().replace(/^description:\s*/i, '').trim();
return { title, description };
}
private _createResult(created: { readonly url: string; readonly number: number }, message: string): InvokeChangesetOperationResult {
const followUp: ChangesetOperationFollowUp = {
content: { uri: created.url, contentType: 'text/html' },
@@ -9,7 +9,8 @@ import { URI } from '../../../base/common/uri.js';
import { ILogService } from '../../log/common/log.js';
import { ISessionDataService } from '../common/sessionDataService.js';
import { ActionType } from '../common/state/sessionActions.js';
import { isAhpChatChannel, isDefaultChatUri, ResponsePartKind, type ResponsePart, type Turn, type URI as ProtocolURI } from '../common/state/sessionState.js';
import { isAhpChatChannel, isDefaultChatUri, type Turn, type URI as ProtocolURI } from '../common/state/sessionState.js';
import { buildConversationContext, renderResponseMarkdown, truncateMiddle } from '../common/agentHostConversationContext.js';
import { AgentHostStateManager } from './agentHostStateManager.js';
import { ICopilotApiService, type ICopilotUtilityChatMessage } from './shared/copilotApiService.js';
@@ -363,7 +364,7 @@ export class AgentHostSessionTitleController extends Disposable {
* title in that case).
*/
private _buildFirstTurnContext(turn: Turn): string | undefined {
const response = this._renderResponseText(turn.responseParts);
const response = renderResponseMarkdown(turn.responseParts);
if (!response) {
return undefined;
}
@@ -371,13 +372,13 @@ export class AgentHostSessionTitleController extends Disposable {
const userBudget = Math.floor(MAX_TITLE_CONTEXT_CHARS / 2);
let userRequest = turn.message.text.trim();
if (userRequest.length > userBudget) {
userRequest = this._truncateMiddle(userRequest, userBudget);
userRequest = truncateMiddle(userRequest, userBudget);
}
const userBlock = `User request:\n${userRequest}`;
const responseLabel = '\n\nAgent response:\n';
const responseBudget = Math.max(0, MAX_TITLE_CONTEXT_CHARS - userBlock.length - responseLabel.length);
const trimmedResponse = response.length > responseBudget ? this._truncateMiddle(response, responseBudget) : response;
const trimmedResponse = response.length > responseBudget ? truncateMiddle(response, responseBudget) : response;
return trimmedResponse ? `${userBlock}${responseLabel}${trimmedResponse}` : userBlock;
}
@@ -389,65 +390,19 @@ export class AgentHostSessionTitleController extends Disposable {
* reasoning, and other parts are ignored, mirroring
* {@link _buildFirstTurnContext}. When the fork's `sourceTitle` is known, a
* short framing note is prepended so the model understands the conversation
* is a branch continued from an earlier chat. The combined text is
* middle-truncated to {@link MAX_TITLE_CONTEXT_CHARS} to bound model cost.
* is a branch continued from an earlier chat. The conversation is
* middle-truncated to {@link MAX_TITLE_CONTEXT_CHARS} to bound model cost;
* the framing note is always preserved in full.
*
* @returns the context string, or `undefined` when no turn carries any
* text worth titling from.
*/
private _buildConversationContext(turns: readonly Turn[], sourceTitle?: string): string | undefined {
const blocks: string[] = [];
for (const turn of turns) {
const userText = turn.message.text.trim();
const responseText = this._renderResponseText(turn.responseParts);
if (!userText && !responseText) {
continue;
}
blocks.push(responseText
? `User request:\n${userText}\n\nAgent response:\n${responseText}`
: `User request:\n${userText}`);
}
if (blocks.length === 0) {
return undefined;
}
const conversation = blocks.join('\n\n---\n\n');
const framedTitle = sourceTitle?.trim();
const framing = framedTitle
? `This conversation was branched from an earlier chat titled "${framedTitle}". The turns below, oldest first, are the inherited history up to the branch point.\n\n`
: '';
const joined = `${framing}${conversation}`;
return joined.length > MAX_TITLE_CONTEXT_CHARS ? this._truncateMiddle(joined, MAX_TITLE_CONTEXT_CHARS) : joined;
}
private _renderResponseText(parts: readonly ResponsePart[]): string {
const segments: string[] = [];
for (const part of parts) {
if (part.kind === ResponsePartKind.Markdown) {
const text = part.content.trim();
if (text) {
segments.push(text);
}
}
}
return segments.join('\n\n');
}
/**
* Truncates `text` to at most `maxChars` characters by removing the middle
* and inserting a `...` marker, preserving the start and end.
*/
private _truncateMiddle(text: string, maxChars: number): string {
if (text.length <= maxChars) {
return text;
}
const marker = '\n...\n';
if (maxChars <= marker.length) {
return text.slice(0, maxChars);
}
const keep = maxChars - marker.length;
const head = Math.ceil(keep / 2);
const tail = keep - head;
return `${text.slice(0, head)}${marker}${text.slice(text.length - tail)}`;
: undefined;
return buildConversationContext(turns, { maxChars: MAX_TITLE_CONTEXT_CHARS, framing });
}
private _persistSessionFlag(session: ProtocolURI, key: string, value: string): void {
@@ -9,13 +9,40 @@ import type { DisposableStore } from '../../../../base/common/lifecycle.js';
import { URI } from '../../../../base/common/uri.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
import { NullLogService } from '../../../log/common/log.js';
import { GITHUB_REPO_PROTECTED_RESOURCE, type IAgentService } from '../../common/agentService.js';
import { GITHUB_COPILOT_PROTECTED_RESOURCE, GITHUB_REPO_PROTECTED_RESOURCE, type IAgentService } from '../../common/agentService.js';
import { buildSessionChangesetUri } from '../../common/changesetUri.js';
import { withSessionGitHubState, withSessionGitState, type ISessionFileDiff, SessionStatus } from '../../common/state/sessionState.js';
import { withSessionGitHubState, withSessionGitState, type ISessionFileDiff, MessageKind, ResponsePartKind, SessionStatus, TurnState, type Turn } from '../../common/state/sessionState.js';
import type { IAgentHostGitService, IPushOptions } from '../../common/agentHostGitService.js';
import { AgentHostPullRequestOperationHandler } from '../../node/agentHostPullRequestOperationHandler.js';
import { AgentHostStateManager } from '../../node/agentHostStateManager.js';
import type { CreatedPullRequest, IAgentHostOctoKitService } from '../../node/shared/agentHostOctoKitService.js';
import type { ICopilotApiService, ICopilotApiServiceRequestOptions, ICopilotUtilityChatCompletionRequest } from '../../node/shared/copilotApiService.js';
import type Anthropic from '@anthropic-ai/sdk';
import type { CCAModel } from '@vscode/copilot-api';
class TestCopilotApiService implements ICopilotApiService {
declare readonly _serviceBrand: undefined;
readonly calls: { token: string; request: ICopilotUtilityChatCompletionRequest; options?: ICopilotApiServiceRequestOptions }[] = [];
response = 'Generated PR title\n\nGenerated PR description.';
error: Error | undefined;
messages(_githubToken: string, _request: Anthropic.MessageCreateParamsStreaming, _options?: ICopilotApiServiceRequestOptions): AsyncGenerator<Anthropic.MessageStreamEvent>;
messages(_githubToken: string, _request: Anthropic.MessageCreateParamsNonStreaming, _options?: ICopilotApiServiceRequestOptions): Promise<Anthropic.Message>;
messages(): AsyncGenerator<Anthropic.MessageStreamEvent> | Promise<Anthropic.Message> {
throw new Error('not used');
}
async countTokens(): Promise<Anthropic.MessageTokensCount> { throw new Error('not used'); }
async models(): Promise<CCAModel[]> { return []; }
async responses(): Promise<Response> { throw new Error('not used'); }
async utilityChatCompletion(githubToken: string, request: ICopilotUtilityChatCompletionRequest, options?: ICopilotApiServiceRequestOptions): Promise<string> {
this.calls.push({ token: githubToken, request, options });
if (this.error) {
throw this.error;
}
return this.response;
}
}
class TestGitService implements IAgentHostGitService {
declare readonly _serviceBrand: undefined;
@@ -75,9 +102,13 @@ class TestOctoKitService implements IAgentHostOctoKitService {
createError: Error | undefined;
findAfterCreateError: Error | undefined;
created: CreatedPullRequest = { url: 'https://github.com/microsoft/vscode/pull/123', number: 123 };
lastTitle: string | undefined;
lastBody: string | undefined;
async createPullRequest(_owner: string, _repo: string, _title: string, _body: string, _head: string, _base: string, draft: boolean, _token: string, _signal: AbortSignal): Promise<CreatedPullRequest> {
async createPullRequest(_owner: string, _repo: string, title: string, body: string, _head: string, _base: string, draft: boolean, _token: string, _signal: AbortSignal): Promise<CreatedPullRequest> {
this.calls.push(`createPullRequest:${draft}`);
this.lastTitle = title;
this.lastBody = body;
if (this.createError) {
throw this.createError;
}
@@ -95,13 +126,21 @@ class TestOctoKitService implements IAgentHostOctoKitService {
}
}
function createAgentService(): IAgentService {
function createAgentService(withCopilotToken = false): IAgentService {
return {
getAuthToken: resource => resource.resource === GITHUB_REPO_PROTECTED_RESOURCE.resource ? 'gh-token' : undefined,
getAuthToken: resource => {
if (resource.resource === GITHUB_REPO_PROTECTED_RESOURCE.resource) {
return 'gh-token';
}
if (withCopilotToken && resource.resource === GITHUB_COPILOT_PROTECTED_RESOURCE.resource) {
return 'copilot-token';
}
return undefined;
},
} as IAgentService;
}
function setup(disposables: Pick<DisposableStore, 'add'>, gitService: TestGitService, octoKitService: TestOctoKitService): { handler: AgentHostPullRequestOperationHandler; session: URI; createdEvents: string[] } {
function setup(disposables: Pick<DisposableStore, 'add'>, gitService: TestGitService, octoKitService: TestOctoKitService, options?: { copilotApiService?: TestCopilotApiService; withCopilotToken?: boolean; turns?: Turn[] }): { handler: AgentHostPullRequestOperationHandler; session: URI; createdEvents: string[]; copilotApiService: TestCopilotApiService } {
const stateManager = disposables.add(new AgentHostStateManager(new NullLogService()));
const session = URI.parse('agent:/session');
const createdEvents: string[] = [];
@@ -125,14 +164,22 @@ function setup(disposables: Pick<DisposableStore, 'add'>, gitService: TestGitSer
owner: 'microsoft',
repo: 'vscode',
}));
const copilotApiService = options?.copilotApiService ?? new TestCopilotApiService();
return {
handler: new AgentHostPullRequestOperationHandler(
false,
sessionKey => stateManager.getSessionState(sessionKey),
sessionKey => {
const state = stateManager.getSessionState(sessionKey);
if (state && options?.turns) {
return { ...state, turns: options.turns };
}
return state;
},
event => createdEvents.push(`${event.sessionKey}:${event.pullRequestUrl}`),
createAgentService(), gitService, octoKitService, new NullLogService()),
createAgentService(options?.withCopilotToken), gitService, octoKitService, copilotApiService, new NullLogService()),
session,
createdEvents,
copilotApiService,
};
}
@@ -277,4 +324,87 @@ suite('AgentHostPullRequestOperationHandler', () => {
createdEvents: [],
});
});
// When a Copilot token is available, the handler asks the utility model
// for a title/description, feeding it the main session conversation (only
// the markdown text of requests/responses — reasoning, tool calls, and
// subagents are excluded) plus the changed-file summary.
test('generates the PR title and description from the conversation via the model', async () => {
const gitService = new TestGitService();
const octoKitService = new TestOctoKitService();
const turns: Turn[] = [{
id: 'turn-1',
message: { text: 'Add retry logic to the uploader', origin: { kind: MessageKind.User } },
responseParts: [
{ kind: ResponsePartKind.Reasoning, id: 'r1', content: 'SECRET_REASONING_SHOULD_BE_EXCLUDED' },
{ kind: ResponsePartKind.Markdown, id: 'm1', content: 'I added exponential backoff to the uploader.' },
],
usage: undefined,
state: TurnState.Complete,
}];
const { handler, session, copilotApiService } = setup(disposables, gitService, octoKitService, { withCopilotToken: true, turns });
const result = await handler.invoke({ channel: buildSessionChangesetUri(session.toString()), operationId: AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR }, CancellationToken.None);
const userContent = copilotApiService.calls[0]?.request.messages.find(m => m.role === 'user')?.content ?? '';
assert.deepStrictEqual({
message: result.message,
token: copilotApiService.calls[0]?.token,
title: octoKitService.lastTitle,
body: octoKitService.lastBody,
includesUserRequest: userContent.includes('Add retry logic to the uploader'),
includesAgentResponse: userContent.includes('I added exponential backoff to the uploader.'),
excludesReasoning: !userContent.includes('SECRET_REASONING_SHOULD_BE_EXCLUDED'),
}, {
message: { markdown: 'Created pull request [#123](https://github.com/microsoft/vscode/pull/123).' },
token: 'copilot-token',
title: 'Generated PR title',
body: 'Generated PR description.',
includesUserRequest: true,
includesAgentResponse: true,
excludesReasoning: true,
});
});
// Without a Copilot token the model is never called and the handler falls
// back to the branch-name based title/description.
test('falls back to branch-name title and description without a Copilot token', async () => {
const gitService = new TestGitService();
const octoKitService = new TestOctoKitService();
const { handler, session, copilotApiService } = setup(disposables, gitService, octoKitService);
await handler.invoke({ channel: buildSessionChangesetUri(session.toString()), operationId: AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR }, CancellationToken.None);
assert.deepStrictEqual({
utilityCalls: copilotApiService.calls.length,
title: octoKitService.lastTitle,
body: octoKitService.lastBody,
}, {
utilityCalls: 0,
title: 'feature: test',
body: 'Created from `feature/test` targeting `main`.',
});
});
// Model failures must not block PR creation — the handler falls back to the
// branch-name based title/description.
test('falls back to branch-name title and description when generation fails', async () => {
const gitService = new TestGitService();
const octoKitService = new TestOctoKitService();
const copilotApiService = new TestCopilotApiService();
copilotApiService.error = new Error('utility model unavailable');
const { handler, session } = setup(disposables, gitService, octoKitService, { withCopilotToken: true, copilotApiService });
const result = await handler.invoke({ channel: buildSessionChangesetUri(session.toString()), operationId: AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR }, CancellationToken.None);
assert.deepStrictEqual({
message: result.message,
title: octoKitService.lastTitle,
body: octoKitService.lastBody,
}, {
message: { markdown: 'Created pull request [#123](https://github.com/microsoft/vscode/pull/123).' },
title: 'feature: test',
body: 'Created from `feature/test` targeting `main`.',
});
});
});