mirror of
https://github.com/microsoft/vscode.git
synced 2026-07-16 17:03:29 +01:00
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:
committed by
GitHub
parent
4c2fab7133
commit
9012e0e98f
@@ -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`.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user