diff --git a/src/vs/platform/agentHost/common/agentHostConversationContext.ts b/src/vs/platform/agentHost/common/agentHostConversationContext.ts new file mode 100644 index 00000000000..b774fe03746 --- /dev/null +++ b/src/vs/platform/agentHost/common/agentHostConversationContext.ts @@ -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)}`; +} diff --git a/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts b/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts index 2f97b717b35..79249e8b4e1 100644 --- a/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts +++ b/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts @@ -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(/^"(?.+)"$/, (_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' }, diff --git a/src/vs/platform/agentHost/node/agentHostSessionTitleController.ts b/src/vs/platform/agentHost/node/agentHostSessionTitleController.ts index a154d3b0e5a..33e279a58a3 100644 --- a/src/vs/platform/agentHost/node/agentHostSessionTitleController.ts +++ b/src/vs/platform/agentHost/node/agentHostSessionTitleController.ts @@ -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 { diff --git a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts index 789f830b187..cd9496afa12 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts @@ -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; + messages(_githubToken: string, _request: Anthropic.MessageCreateParamsNonStreaming, _options?: ICopilotApiServiceRequestOptions): Promise; + messages(): AsyncGenerator | Promise { + throw new Error('not used'); + } + async countTokens(): Promise { throw new Error('not used'); } + async models(): Promise { return []; } + async responses(): Promise { throw new Error('not used'); } + async utilityChatCompletion(githubToken: string, request: ICopilotUtilityChatCompletionRequest, options?: ICopilotApiServiceRequestOptions): Promise { + 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 { + async createPullRequest(_owner: string, _repo: string, title: string, body: string, _head: string, _base: string, draft: boolean, _token: string, _signal: AbortSignal): Promise { 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, gitService: TestGitService, octoKitService: TestOctoKitService): { handler: AgentHostPullRequestOperationHandler; session: URI; createdEvents: string[] } { +function setup(disposables: Pick, 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, 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`.', + }); + }); });