diff --git a/ts/packages/agents/github-cli/README.md b/ts/packages/agents/github-cli/README.md index cc122c0256..95909bc5a6 100644 --- a/ts/packages/agents/github-cli/README.md +++ b/ts/packages/agents/github-cli/README.md @@ -44,6 +44,34 @@ fork microsoft/TypeAgent star microsoft/TypeAgent ``` +## Local merge conflict resolution + +Say `resolve merge conflicts` or `resolve merge conflicts from main` to merge +the source branch into the currently checked-out local branch. The default +source is the remote's default branch, falling back to an existing `main` or +`master`. This action does not check out another branch or push. + +Both merge actions require the working directory supplied by the host session. +They never use the agent server's working directory as a fallback. + +Clean merges are committed locally. For conflicts, Reasoning uses its native +file and terminal tools in the repository root to inspect, resolve, and stage +only the conflicted paths. No connected editor extension is required. The +dispatcher then runs a separate completion action that verifies the index and +creates the merge commit; a model's text response alone is not completion. + +Completion checks the saved post-merge staged paths and original conflicts, +including changes Git automatically applied through renames. The saved state is +bound to the original `HEAD` and `MERGE_HEAD`; missing, outdated, or mismatched +state requires manual inspection and commit or abort. + +Cancellation stops further Git operations and cleans up the temporary fetch ref. +If a merge has already started, it is left in place rather than automatically +reset or aborted. Inspect `git status` before continuing. Resolve and stage the +listed files, then run `completeMergeConflictResolution` in the same host session +repository, or explicitly abort with `git merge --abort`. Do not start another +merge on top of it. + ## Output Formatting - PR, issue, and repo listings include clickable **hyperlinks** diff --git a/ts/packages/agents/github-cli/src/github-cliActionHandler.ts b/ts/packages/agents/github-cli/src/github-cliActionHandler.ts index 6d3fed2141..9b963340c6 100644 --- a/ts/packages/agents/github-cli/src/github-cliActionHandler.ts +++ b/ts/packages/agents/github-cli/src/github-cliActionHandler.ts @@ -37,6 +37,11 @@ import { runSetupCommand, whichExists, } from "./setup.js"; +import { + MergeConflictResult, + completeMergeConflictResolution, + mergeAndCommit, +} from "./mergeConflict.js"; import { buildTableBlock } from "./structuredResults.js"; import { GhResult, runPrFailedChecks, runPrFiles } from "./prDiagnostics.js"; @@ -463,6 +468,12 @@ export function buildArgs( const p = action.parameters as Record; switch (action.actionName) { + case "resolveMergeConflicts": + case "completeMergeConflictResolution": + // This action uses the narrowly scoped local git workflow below, + // never the general-purpose gh argument marshaller. + return undefined; + // ── Auth ── case "authLogin": { const args = ["auth", "login"]; @@ -1941,11 +1952,122 @@ export async function validateAndResolveRepo( }; } +export function getRequestedMergeTarget(action: { + actionName?: string; + parameters?: { targetBranch?: string }; +}): string | undefined { + return action.parameters?.targetBranch; +} + +function buildMergeFailure( + result: Extract, +): ActionResult { + const recovery = + result.recovery === undefined ? "" : `\n\n${result.recovery}`; + return { + error: JSON.stringify(result), + errorCode: result.errorCode, + retryable: !result.mayHaveSideEffects, + mayHaveSideEffects: result.mayHaveSideEffects, + errorDisplayContent: { + type: "markdown", + content: `**${result.message}**${recovery}`, + }, + }; +} + +export function buildMergeResult(result: MergeConflictResult): ActionResult { + if (result.status === "blocked") { + return buildMergeFailure(result); + } + + const target = result.target?.displayName; + const summary = + result.status === "committed" + ? `Created merge commit ${result.commit.slice(0, 12)}${target ? ` from ${target}` : ""}.` + : result.status === "upToDate" + ? `${target} is already incorporated.` + : `Merge from ${target} has ${result.conflicts.length} conflict(s). Reasoning will resolve them.`; + const blocks: StructuredBlock[] = [ + { kind: "heading", level: 3, text: summary }, + ]; + if (result.status === "conflicts") { + blocks.push({ + kind: "text", + format: "markdown", + text: result.conflicts.map((file) => `- \`${file}\``).join("\n"), + }); + blocks.push({ + kind: "text", + format: "markdown", + text: `The merge is in progress in \`${result.repositoryRoot}\`. If Reasoning cannot finish it, run \`git -C "${result.repositoryRoot}" merge --abort\`.`, + }); + } + const actionResult: ActionResultSuccess = { + historyText: JSON.stringify(result), + entities: [], + resultValue: result, + displayContent: createStructuredContent(blocks, { rawData: result }), + }; + if (result.status === "conflicts") { + const files = result.conflicts.map((file) => `- ${file}`).join("\n"); + actionResult.additionalActions = [ + { + schemaName: "dispatcher.reasoning", + actionName: "reasoningAction", + parameters: { + originalRequest: + `Resolve the current Git merge conflicts in the repository at ${result.repositoryRoot}. ` + + `Treat every path below as relative to that root, and run every Git command with that exact repository as its working directory:\n${files}\n\n` + + "Inspect both sides and preserve the intent of each change. Edit and stage only these conflicted paths. Do not edit unrelated files, abort the merge, commit, or push. " + + "Use your native file and terminal tools directly; do not delegate this task to another agent or an editor extension. " + + "Stage each resolved path with git add or git rm, then return. " + + "The dispatcher will run the completion action next to verify the staged resolution and create the merge commit. " + + "Do not invoke the completion action yourself or claim a merge commit was created.", + reason: "The merge produced file conflicts that require semantic resolution.", + workingDirectory: result.repositoryRoot, + }, + }, + { + schemaName: "github-cli", + actionName: "completeMergeConflictResolution", + parameters: { repositoryRoot: result.repositoryRoot }, + }, + ]; + } + return actionResult; +} + // code-complexity-allow: top-level action dispatch over all github-cli actions async function executeAction( action: TypeAgentAction, context: ActionContext, ): Promise { + if ( + action.actionName === "resolveMergeConflicts" || + action.actionName === "completeMergeConflictResolution" + ) { + // The server's cwd may belong to an unrelated repository. + if (!context.workingDirectory) { + return buildMergeFailure({ + status: "blocked", + errorCode: "notRepository", + message: + "The host did not provide a working directory for this action. Open a session in a Git repository and retry.", + mayHaveSideEffects: false, + }); + } + const options = { + cwd: context.workingDirectory, + signal: context.abortSignal, + }; + return buildMergeResult( + action.actionName === "resolveMergeConflicts" + ? await mergeAndCommit(getRequestedMergeTarget(action), options) + : await completeMergeConflictResolution(options), + ); + } + // Bare-name repo guard — see validateAndResolveRepo. Runs before // buildArgs so we never hand `gh` a malformed --repo value. const validated = await validateAndResolveRepo( @@ -1955,6 +2077,7 @@ async function executeAction( if (validated.kind === "clarify") { return validated.result; } + action = validated.action; // Multi-call read-only diagnostics. These compose several gh invocations diff --git a/ts/packages/agents/github-cli/src/github-cliSchema.agr b/ts/packages/agents/github-cli/src/github-cliSchema.agr index 04865236f8..cdb90e32fa 100644 --- a/ts/packages/agents/github-cli/src/github-cliSchema.agr +++ b/ts/packages/agents/github-cli/src/github-cliSchema.agr @@ -538,6 +538,27 @@ } }; + = resolve merge conflicts from $(targetBranch:wildcard) -> { + actionName: "resolveMergeConflicts", + parameters: { + targetBranch + } +} + | bring $(targetBranch:wildcard) into this branch and resolve conflicts -> { + actionName: "resolveMergeConflicts", + parameters: { + targetBranch + } +} + | bring the default branch into this branch and resolve conflicts -> { + actionName: "resolveMergeConflicts", + parameters: {} +} + | merge the default branch into this branch and resolve conflicts -> { + actionName: "resolveMergeConflicts", + parameters: {} +}; + import { GithubCliActions } from "./github-cliSchema.ts"; : GithubCliActions = @@ -576,4 +597,5 @@ import { GithubCliActions } from "./github-cliSchema.ts"; | | | + | | ; diff --git a/ts/packages/agents/github-cli/src/github-cliSchema.ts b/ts/packages/agents/github-cli/src/github-cliSchema.ts index e34e59c097..ab616c532e 100644 --- a/ts/packages/agents/github-cli/src/github-cliSchema.ts +++ b/ts/packages/agents/github-cli/src/github-cliSchema.ts @@ -69,7 +69,9 @@ export type GithubCliActions = | MyPullRequestsAction | IssueAddLabelAction | VariableCreateAction - | DependabotAlertsAction; + | DependabotAlertsAction + | ResolveMergeConflictsAction + | CompleteMergeConflictResolutionAction; export type AuthLoginAction = { actionName: "authLogin"; @@ -782,3 +784,33 @@ export type DependabotAlertsAction = { state?: string; }; }; + +// Fetch a source branch, merge it into the currently checked-out local branch, +// and create the merge commit. This action never checks out a different +// destination branch. If Git reports conflicts, hand the conflicted files to +// Reasoning for semantic resolution before the deterministic completion action +// commits. +// Use this for requests such as "resolve merge conflicts from main" or "bring +// the default branch into this branch and resolve conflicts". This never pushes. +export type ResolveMergeConflictsAction = { + actionName: "resolveMergeConflicts"; + parameters: { + // Source branch to merge into the currently checked-out local branch. A + // REMOTE/BRANCH value disambiguates repositories with multiple remotes. + // When omitted, use the selected remote's configured default branch, + // then an existing main or master branch. + targetBranch?: string; + }; +}; + +// Complete a conflicted merge after Reasoning has resolved and staged every +// conflicted path. This verifies that no unmerged or unstaged paths remain, +// creates the merge commit, and never pushes. Usually invoked by Reasoning +// rather than selected directly from a user request. +export type CompleteMergeConflictResolutionAction = { + actionName: "completeMergeConflictResolution"; + parameters: { + // Recorded merge root; execution always uses the host-authorized working directory. + repositoryRoot: string; + }; +}; diff --git a/ts/packages/agents/github-cli/src/mergeConflict.ts b/ts/packages/agents/github-cli/src/mergeConflict.ts new file mode 100644 index 0000000000..ec4d0e5c65 --- /dev/null +++ b/ts/packages/agents/github-cli/src/mergeConflict.ts @@ -0,0 +1,743 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { execFile } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +export type GitCommandResult = { + exitCode: number; + stdout: string; + stderr: string; +}; + +export type GitCommandRunner = ( + args: readonly string[], + cwd?: string, + signal?: AbortSignal, +) => Promise; + +export type MergeTarget = { + remote: string; + branch: string; + displayName: string; +}; + +export type MergeConflictResult = + | { + status: "committed"; + repositoryRoot: string; + currentBranch: string; + target?: MergeTarget; + commit: string; + } + | { + status: "conflicts"; + repositoryRoot: string; + currentBranch: string; + target: MergeTarget; + conflicts: string[]; + } + | { + status: "upToDate"; + repositoryRoot: string; + currentBranch: string; + target: MergeTarget; + } + | { + status: "blocked"; + errorCode: + | "notRepository" + | "detachedHead" + | "dirtyWorktree" + | "operationInProgress" + | "noMergeInProgress" + | "missingRemote" + | "ambiguousRemote" + | "missingTargetBranch" + | "fetchFailed" + | "mergeFailed" + | "unresolvedConflicts" + | "missingResolutionState" + | "unstagedChanges" + | "unrelatedChanges" + | "conflictMarkers" + | "commitFailed"; + message: string; + recovery?: string; + conflicts?: string[]; + mayHaveSideEffects: boolean; + }; + +export type MergeOptions = { + cwd?: string; + signal?: AbortSignal | undefined; + runGit?: GitCommandRunner; + pathExists?: (filePath: string) => boolean; + readFile?: (filePath: string) => string; + writeFile?: (filePath: string, content: string) => void; + removeFile?: (filePath: string) => void; +}; + +// Bind the actual post-merge index paths to this merge, including edits +// Git automatically applied through renames. +type ResolutionState = { + version: 1; + head: string; + mergeHead: string; + conflicts: string[]; + stagedPaths: string[]; +}; + +function abortError(message = "The operation was aborted."): Error { + return new DOMException(message, "AbortError"); +} + +function isAbortError(error: unknown): boolean { + return ( + error instanceof Error && + (error.name === "AbortError" || + ("code" in error && error.code === "ABORT_ERR")) + ); +} + +function throwIfAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted) { + throw abortError(); + } +} + +function cancellableGit(options: MergeOptions): GitCommandRunner { + const runGit = options.runGit ?? runGitCommand; + return (args, cwd) => { + throwIfAborted(options.signal); + return runGit(args, cwd, options.signal); + }; +} + +export async function runGitCommand( + args: readonly string[], + cwd = process.cwd(), + signal?: AbortSignal, +): Promise { + try { + const { stdout, stderr } = await execFileAsync("git", [...args], { + cwd, + encoding: "utf8", + maxBuffer: 4 * 1024 * 1024, + timeout: 10 * 60_000, + windowsHide: true, + signal, + }); + return { exitCode: 0, stdout, stderr }; + } catch (error) { + // Let the dispatcher distinguish cancellation from a failed Git command. + if (isAbortError(error) || signal?.aborted) { + throw abortError(); + } + const failure = error as Error & { + code?: number; + stdout?: string; + stderr?: string; + }; + return { + exitCode: + typeof failure.code === "number" ? failure.code : Number.NaN, + stdout: String(failure.stdout ?? ""), + stderr: String(failure.stderr ?? failure.message), + }; + } +} + +function blocked( + errorCode: Extract["errorCode"], + message: string, + mayHaveSideEffects = false, + details: Partial> = {}, +): MergeConflictResult { + return { + status: "blocked", + errorCode, + message, + mayHaveSideEffects, + ...details, + }; +} + +function lines(output: string): string[] { + return output + .split(/\r?\n/) + .map((value) => value.trim()) + .filter(Boolean); +} + +function nullSeparated(output: string): string[] { + return output.split("\0").filter(Boolean); +} + +async function getRepository( + cwd: string, + runGit: GitCommandRunner, +): Promise< + { repositoryRoot: string; currentBranch: string } | MergeConflictResult +> { + const root = await runGit(["rev-parse", "--show-toplevel"], cwd); + if (root.exitCode !== 0) { + return blocked( + "notRepository", + "Run this action from a Git repository.", + ); + } + const repositoryRoot = root.stdout.trim(); + const branch = await runGit( + ["symbolic-ref", "--quiet", "--short", "HEAD"], + repositoryRoot, + ); + if (branch.exitCode !== 0) { + return blocked( + "detachedHead", + "Check out a local branch before merging.", + ); + } + return { repositoryRoot, currentBranch: branch.stdout.trim() }; +} + +async function findOperation( + repositoryRoot: string, + runGit: GitCommandRunner, + pathExists: (filePath: string) => boolean, +): Promise { + for (const [gitPath, operation] of [ + ["MERGE_HEAD", "merge"], + ["rebase-merge", "rebase"], + ["rebase-apply", "rebase"], + ["CHERRY_PICK_HEAD", "cherry-pick"], + ["REVERT_HEAD", "revert"], + ]) { + const result = await runGit( + ["rev-parse", "--git-path", gitPath], + repositoryRoot, + ); + if (result.exitCode === 0) { + const resolved = path.resolve(repositoryRoot, result.stdout.trim()); + if (pathExists(resolved)) { + return operation; + } + } + } + return undefined; +} + +function selectRemote( + remotes: string[], + explicitRemote: string | undefined, +): string | MergeConflictResult { + if (explicitRemote !== undefined) { + return remotes.includes(explicitRemote) + ? explicitRemote + : blocked( + "missingRemote", + `Remote '${explicitRemote}' does not exist.`, + ); + } + if (remotes.includes("origin")) { + return "origin"; + } + if (remotes.length === 1) { + return remotes[0]; + } + if (remotes.length === 0) { + return blocked("missingRemote", "This repository has no Git remote."); + } + return blocked( + "ambiguousRemote", + `Choose a remote explicitly. Available remotes: ${remotes.join(", ")}.`, + ); +} + +function parseTarget( + targetBranch: string | undefined, + remotes: string[], +): { + remote?: string; + branch?: string; +} { + if (targetBranch === undefined) { + return {}; + } + const slash = targetBranch.indexOf("/"); + return slash > 0 && remotes.includes(targetBranch.slice(0, slash)) + ? { + remote: targetBranch.slice(0, slash), + branch: targetBranch.slice(slash + 1), + } + : { branch: targetBranch }; +} + +async function getDefaultBranch( + repositoryRoot: string, + remote: string, + runGit: GitCommandRunner, +): Promise { + const head = await runGit( + ["ls-remote", "--symref", remote, "HEAD"], + repositoryRoot, + ); + const match = /^ref:\s+refs\/heads\/(.+)\s+HEAD$/m.exec(head.stdout); + if (head.exitCode === 0 && match?.[1]) { + return match[1]; + } + for (const fallback of ["main", "master"]) { + const result = await runGit( + ["ls-remote", "--exit-code", "--heads", remote, fallback], + repositoryRoot, + ); + if (result.exitCode === 0 && result.stdout.trim() !== "") { + return fallback; + } + } + return undefined; +} + +async function resolveTarget( + targetBranch: string | undefined, + repositoryRoot: string, + runGit: GitCommandRunner, +): Promise { + const remoteResult = await runGit(["remote"], repositoryRoot); + const remotes = lines(remoteResult.stdout); + const requested = parseTarget(targetBranch?.trim() || undefined, remotes); + const remote = selectRemote(remotes, requested.remote); + if (typeof remote !== "string") { + return remote; + } + const branch = + requested.branch ?? + (await getDefaultBranch(repositoryRoot, remote, runGit)); + if (branch === undefined || branch === "") { + return blocked( + "missingTargetBranch", + `Could not determine the default branch for '${remote}'. Specify a target branch.`, + ); + } + const validBranch = await runGit( + ["check-ref-format", "--branch", branch], + repositoryRoot, + ); + if (validBranch.exitCode !== 0) { + return blocked( + "missingTargetBranch", + `'${branch}' is not a valid Git branch name.`, + ); + } + return { remote, branch, displayName: `${remote}/${branch}` }; +} + +async function listConflicts( + repositoryRoot: string, + runGit: GitCommandRunner, +): Promise { + const result = await runGit( + ["diff", "--name-only", "--diff-filter=U", "-z"], + repositoryRoot, + ); + return result.exitCode === 0 ? nullSeparated(result.stdout) : []; +} + +async function getMergeParents( + repositoryRoot: string, + runGit: GitCommandRunner, +): Promise<{ head: string; mergeHead: string } | undefined> { + const result = await runGit( + ["rev-parse", "HEAD", "MERGE_HEAD"], + repositoryRoot, + ); + const [head, mergeHead] = lines(result.stdout); + return result.exitCode === 0 && head && mergeHead + ? { head, mergeHead } + : undefined; +} + +async function getResolutionStatePath( + repositoryRoot: string, + runGit: GitCommandRunner, +): Promise { + const result = await runGit( + ["rev-parse", "--git-path", "TYPEAGENT_MERGE_CONFLICTS"], + repositoryRoot, + ); + return result.exitCode === 0 + ? path.resolve(repositoryRoot, result.stdout.trim()) + : undefined; +} + +async function commitMerge( + repositoryRoot: string, + runGit: GitCommandRunner, +): Promise { + const commit = await runGit(["commit", "--no-edit"], repositoryRoot); + if (commit.exitCode !== 0) { + return blocked( + "commitFailed", + commit.stderr.trim() || "Git could not create the merge commit.", + true, + { + recovery: + "Resolve the error, then run `git commit` or `git merge --abort`.", + }, + ); + } + const head = await runGit(["rev-parse", "HEAD"], repositoryRoot); + return head.exitCode === 0 ? head.stdout.trim() : ""; +} + +async function listChangedPaths( + repositoryRoot: string, + runGit: GitCommandRunner, + args: readonly string[], +): Promise { + const result = await runGit([...args, "-z"], repositoryRoot); + return result.exitCode === 0 ? nullSeparated(result.stdout) : undefined; +} + +async function deleteTemporaryRef( + repositoryRoot: string, + runGit: GitCommandRunner, + ref: string, +): Promise { + // Cleanup must run even after cancellation, without masking its AbortError. + try { + const result = await runGit(["update-ref", "-d", ref], repositoryRoot); + if (result.exitCode !== 0) { + throw new Error(result.stderr || "git update-ref failed"); + } + } catch (error) { + console.warn( + `Could not remove temporary merge ref ${ref} in ${repositoryRoot}: ${String(error)}`, + ); + } +} + +export async function mergeAndCommit( + targetBranch?: string, + options: MergeOptions = {}, +): Promise { + const cwd = options.cwd ?? process.cwd(); + const runGit = cancellableGit(options); + const pathExists = options.pathExists ?? fs.existsSync; + const writeFile = + options.writeFile ?? + ((filePath, content) => fs.writeFileSync(filePath, content, "utf8")); + const removeFile = + options.removeFile ?? + ((filePath) => fs.rmSync(filePath, { force: true })); + + const repository = await getRepository(cwd, runGit); + if ("status" in repository) { + return repository; + } + const { repositoryRoot, currentBranch } = repository; + const resolutionStatePath = await getResolutionStatePath( + repositoryRoot, + runGit, + ); + const operation = await findOperation(repositoryRoot, runGit, pathExists); + if (operation !== undefined) { + return blocked( + "operationInProgress", + `Finish or abort the current ${operation} before starting another merge.`, + ); + } + const status = await runGit( + ["status", "--porcelain=v1", "-z", "--untracked-files=all"], + repositoryRoot, + ); + if (status.exitCode !== 0 || status.stdout !== "") { + return blocked( + "dirtyWorktree", + "Commit or stash local changes before merging.", + ); + } + if (resolutionStatePath !== undefined && pathExists(resolutionStatePath)) { + removeFile(resolutionStatePath); + } + const target = await resolveTarget(targetBranch, repositoryRoot, runGit); + if ("status" in target) { + return target; + } + const temporaryRef = `refs/typeagent/merge/${randomUUID()}`; + try { + const fetch = await runGit( + [ + "fetch", + "--no-tags", + "--no-write-fetch-head", + target.remote, + `refs/heads/${target.branch}:${temporaryRef}`, + ], + repositoryRoot, + ); + if (fetch.exitCode !== 0) { + return blocked( + "fetchFailed", + fetch.stderr.trim() || `Could not fetch ${target.displayName}.`, + ); + } + // This invocation's unique ref pins the fetched target until cleanup. + const merge = await runGit( + ["merge", "--no-commit", "--no-ff", temporaryRef], + repositoryRoot, + ); + if (merge.exitCode !== 0) { + const conflicts = await listConflicts(repositoryRoot, runGit); + if (conflicts.length > 0) { + if (resolutionStatePath === undefined) { + return blocked( + "mergeFailed", + "Git could not create conflict-resolution state.", + true, + { recovery: "Run `git merge --abort`." }, + ); + } + const stagedSnapshot = await listChangedPaths( + repositoryRoot, + runGit, + ["diff", "--cached", "--name-only", "HEAD"], + ); + const parents = await getMergeParents(repositoryRoot, runGit); + if (stagedSnapshot === undefined || parents === undefined) { + return blocked( + "mergeFailed", + "Git could not capture post-merge state.", + true, + { recovery: "Run `git merge --abort`." }, + ); + } + const state: ResolutionState = { + version: 1, + ...parents, + conflicts, + stagedPaths: stagedSnapshot, + }; + try { + writeFile(resolutionStatePath, JSON.stringify(state)); + } catch (error) { + return blocked( + "mergeFailed", + `Could not save conflict-resolution state: ${String(error)}`, + true, + { recovery: "Run `git merge --abort`." }, + ); + } + return { + status: "conflicts", + repositoryRoot, + currentBranch, + target, + conflicts, + }; + } + return blocked( + "mergeFailed", + merge.stderr.trim() || "Git could not merge the target branch.", + true, + { + recovery: + "Inspect `git status`, then run `git merge --abort` if needed.", + }, + ); + } + if (!(await getMergeParents(repositoryRoot, runGit))) { + return { + status: "upToDate", + repositoryRoot, + currentBranch, + target, + }; + } + const commit = await commitMerge(repositoryRoot, runGit); + return typeof commit === "string" + ? { + status: "committed", + repositoryRoot, + currentBranch, + target, + commit, + } + : commit; + } finally { + await deleteTemporaryRef( + repositoryRoot, + options.runGit ?? runGitCommand, + temporaryRef, + ); + } +} + +function parseResolutionState(raw: string): ResolutionState | undefined { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return undefined; + } + if ( + parsed === null || + typeof parsed !== "object" || + Array.isArray(parsed) + ) { + return undefined; + } + const candidate = parsed as Record; + if ( + candidate.version !== 1 || + typeof candidate.head !== "string" || + typeof candidate.mergeHead !== "string" || + !Array.isArray(candidate.conflicts) || + !candidate.conflicts.every((value) => typeof value === "string") || + !Array.isArray(candidate.stagedPaths) || + !candidate.stagedPaths.every((value) => typeof value === "string") + ) { + return undefined; + } + return { + version: 1, + head: candidate.head, + mergeHead: candidate.mergeHead, + conflicts: candidate.conflicts, + stagedPaths: candidate.stagedPaths, + }; +} + +export async function completeMergeConflictResolution( + options: MergeOptions = {}, +): Promise { + const cwd = options.cwd ?? process.cwd(); + const runGit = cancellableGit(options); + const pathExists = options.pathExists ?? fs.existsSync; + const readFile = + options.readFile ?? ((filePath) => fs.readFileSync(filePath, "utf8")); + const removeFile = + options.removeFile ?? + ((filePath) => fs.rmSync(filePath, { force: true })); + + const repository = await getRepository(cwd, runGit); + if ("status" in repository) { + return repository; + } + const { repositoryRoot, currentBranch } = repository; + const resolutionStatePath = await getResolutionStatePath( + repositoryRoot, + runGit, + ); + const parents = await getMergeParents(repositoryRoot, runGit); + if (parents === undefined) { + return blocked( + "noMergeInProgress", + "There is no merge in progress to complete.", + ); + } + const conflicts = await listConflicts(repositoryRoot, runGit); + if (conflicts.length > 0) { + return blocked( + "unresolvedConflicts", + "Resolve and stage every conflicted file before completing the merge.", + true, + { conflicts }, + ); + } + if (resolutionStatePath === undefined || !pathExists(resolutionStatePath)) { + return blocked( + "missingResolutionState", + "Conflict-resolution state is missing. Inspect the merge and commit or abort it manually.", + true, + ); + } + let raw: string; + try { + raw = readFile(resolutionStatePath); + } catch (error) { + return blocked( + "missingResolutionState", + `Could not read conflict-resolution state: ${String(error)}`, + true, + ); + } + const state = parseResolutionState(raw); + if (state === undefined) { + return blocked( + "missingResolutionState", + "Conflict-resolution state is invalid or from an older format. Inspect the merge and commit or abort it manually.", + true, + ); + } + if (parents.head !== state.head || parents.mergeHead !== state.mergeHead) { + return blocked( + "missingResolutionState", + "Conflict-resolution state does not match the current merge. Inspect the merge and commit or abort it manually.", + true, + ); + } + const unstaged = await runGit(["diff", "--quiet"], repositoryRoot); + if (unstaged.exitCode !== 0) { + return blocked( + "unstagedChanges", + "Stage the resolved merge changes before completing the merge.", + true, + ); + } + const stagedPaths = await listChangedPaths(repositoryRoot, runGit, [ + "diff", + "--cached", + "--name-only", + "HEAD", + ]); + if (stagedPaths === undefined) { + return blocked( + "unrelatedChanges", + "Git could not verify the staged merge paths.", + true, + ); + } + const allowed = new Set([...state.stagedPaths, ...state.conflicts]); + const unrelated = stagedPaths.filter((file) => !allowed.has(file)); + if (unrelated.length > 0) { + return blocked( + "unrelatedChanges", + `Unstage changes unrelated to the merge: ${unrelated.join(", ")}.`, + true, + ); + } + const markerCheck = await runGit( + ["diff", "--cached", "--check"], + repositoryRoot, + ); + if ( + markerCheck.exitCode !== 0 && + `${markerCheck.stdout}\n${markerCheck.stderr}`.includes( + "leftover conflict marker", + ) + ) { + return blocked( + "conflictMarkers", + "Remove remaining conflict markers before completing the merge.", + true, + ); + } + const commit = await commitMerge(repositoryRoot, runGit); + if (typeof commit === "string") { + removeFile(resolutionStatePath); + } + return typeof commit === "string" + ? { + status: "committed", + repositoryRoot, + currentBranch, + commit, + } + : commit; +} diff --git a/ts/packages/agents/github-cli/test/githubCliBuildArgs.spec.ts b/ts/packages/agents/github-cli/test/githubCliBuildArgs.spec.ts index c7d9a801c0..b2e46aa26a 100644 --- a/ts/packages/agents/github-cli/test/githubCliBuildArgs.spec.ts +++ b/ts/packages/agents/github-cli/test/githubCliBuildArgs.spec.ts @@ -219,6 +219,15 @@ describe("buildArgs — myPullRequests (cross-repo gh search prs)", () => { expect(joined).toContain("repository"); }); + describe("buildArgs — deterministic local merge actions", () => { + test.each(["resolveMergeConflicts", "completeMergeConflictResolution"])( + "keeps %s outside the generic gh execution path", + (actionName) => { + expect(buildArgs(action(actionName, {}))).toBeUndefined(); + }, + ); + }); + test("honors an explicit state and limit", () => { const args = buildArgs( action("myPullRequests", { state: "closed", limit: 5 }), diff --git a/ts/packages/agents/github-cli/test/mergeConflict.spec.ts b/ts/packages/agents/github-cli/test/mergeConflict.spec.ts new file mode 100644 index 0000000000..d7fb271860 --- /dev/null +++ b/ts/packages/agents/github-cli/test/mergeConflict.spec.ts @@ -0,0 +1,615 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + completeMergeConflictResolution, + mergeAndCommit, +} from "../src/mergeConflict.js"; +import type { + GitCommandResult, + GitCommandRunner, +} from "../src/mergeConflict.js"; +import { + buildMergeResult, + getRequestedMergeTarget, +} from "../src/github-cliActionHandler.js"; + +const ROOT = process.platform === "win32" ? "C:\\repo" : "/repo"; +const HEAD_SHA = "1111111111111111111111111111111111111111"; +const MERGE_HEAD_SHA = "2222222222222222222222222222222222222222"; + +function ok(stdout = ""): GitCommandResult { + return { exitCode: 0, stdout, stderr: "" }; +} + +function fail(stderr = "failed"): GitCommandResult { + return { exitCode: 1, stdout: "", stderr }; +} + +function key(args: readonly string[]): string { + return args.join("\0"); +} + +type RunnerOptions = { + dirty?: boolean; + remotes?: string[]; + defaultBranch?: string; + mainExists?: boolean; + merge?: GitCommandResult; + conflicts?: string[]; + mergeInProgress?: boolean; + unstaged?: boolean; + stagedPaths?: string[]; + markers?: boolean; + whitespaceErrors?: boolean; + commit?: GitCommandResult; + head?: string; + mergeHead?: string; +}; + +function createRunner(options: RunnerOptions = {}): { + runGit: GitCommandRunner; + calls: string[][]; +} { + const head = options.head ?? HEAD_SHA; + const mergeHead = options.mergeHead ?? MERGE_HEAD_SHA; + const calls: string[][] = []; + const runGit: GitCommandRunner = async (args) => { + calls.push([...args]); + switch (key(args)) { + case key(["rev-parse", "--show-toplevel"]): + return ok(ROOT); + case key(["symbolic-ref", "--quiet", "--short", "HEAD"]): + return ok("feature/work"); + case key([ + "status", + "--porcelain=v1", + "-z", + "--untracked-files=all", + ]): + return ok(options.dirty ? " M local.txt\0" : ""); + case key(["remote"]): + return ok((options.remotes ?? ["origin"]).join("\n")); + case key(["ls-remote", "--symref", "origin", "HEAD"]): + return options.defaultBranch === undefined + ? fail() + : ok( + `ref: refs/heads/${options.defaultBranch}\tHEAD\nabc\tHEAD\n`, + ); + case key(["ls-remote", "--exit-code", "--heads", "origin", "main"]): + return options.mainExists + ? ok("abc\trefs/heads/main\n") + : fail(); + case key(["rev-parse", "HEAD", "MERGE_HEAD"]): + return options.mergeInProgress === false + ? fail() + : ok(`${head}\n${mergeHead}`); + case key(["rev-parse", "HEAD"]): + return ok(head); + case key(["diff", "--name-only", "--diff-filter=U", "-z"]): + return ok((options.conflicts ?? []).join("\0")); + case key(["diff", "--quiet"]): + return options.unstaged ? fail() : ok(); + case key(["diff", "--cached", "--name-only", "HEAD", "-z"]): + return ok((options.stagedPaths ?? ["src/a.ts"]).join("\0")); + case key(["diff", "--cached", "--check"]): + return options.markers + ? fail("leftover conflict marker") + : options.whitespaceErrors + ? fail("trailing whitespace") + : ok(); + case key(["commit", "--no-edit"]): + return options.commit ?? ok(); + default: + if (args[0] === "rev-parse" && args[1] === "--git-path") { + return ok(`.git/${args[2]}`); + } + if (args[0] === "check-ref-format" && args[1] === "--branch") { + return args[2]?.startsWith("-") ? fail() : ok(args[2]); + } + if (args[0] === "fetch") { + return ok(); + } + if ( + args[0] === "merge" && + args[1] === "--no-commit" && + args[2] === "--no-ff" && + args[3]?.startsWith("refs/typeagent/merge/") + ) { + return options.merge ?? ok(); + } + if (args[0] === "update-ref" && args[1] === "-d") { + return ok(); + } + if ( + args[0] === "ls-remote" && + args[1] === "--symref" && + args[2] === "upstream" + ) { + return fail(); + } + return fail(`Unexpected command: ${args.join(" ")}`); + } + }; + return { runGit, calls }; +} + +function stateFixture( + overrides: { + head?: string; + mergeHead?: string; + conflicts?: string[]; + stagedPaths?: string[]; + } = {}, +): string { + return JSON.stringify({ + version: 1, + head: overrides.head ?? HEAD_SHA, + mergeHead: overrides.mergeHead ?? MERGE_HEAD_SHA, + conflicts: overrides.conflicts ?? ["src/a.ts"], + stagedPaths: overrides.stagedPaths ?? ["src/a.ts"], + }); +} + +const resolutionState = { + pathExists: (filePath: string) => + filePath.includes("TYPEAGENT_MERGE_CONFLICTS"), + readFile: () => stateFixture(), + removeFile: () => {}, +}; + +describe("mergeAndCommit", () => { + test("uses the remote default branch and creates a merge commit", async () => { + const { runGit, calls } = createRunner({ defaultBranch: "main" }); + const result = await mergeAndCommit(undefined, { + cwd: ROOT, + runGit, + pathExists: () => false, + }); + + expect(result).toMatchObject({ + status: "committed", + currentBranch: "feature/work", + target: { displayName: "origin/main" }, + commit: HEAD_SHA, + }); + expect(calls).toContainEqual([ + "merge", + "--no-commit", + "--no-ff", + expect.stringMatching(/^refs\/typeagent\/merge\//), + ]); + expect(calls).toContainEqual(["commit", "--no-edit"]); + expect(calls.some(([command]) => command === "push")).toBe(false); + }); + + test("falls back to an existing main branch", async () => { + const { runGit, calls } = createRunner({ mainExists: true }); + const result = await mergeAndCommit(undefined, { + cwd: ROOT, + runGit, + pathExists: () => false, + }); + + expect(result).toMatchObject({ + status: "committed", + target: { displayName: "origin/main" }, + }); + expect(calls).toContainEqual([ + "ls-remote", + "--exit-code", + "--heads", + "origin", + "main", + ]); + }); + + test("supports an explicit remote and branch", async () => { + const { runGit, calls } = createRunner({ + remotes: ["origin", "upstream"], + }); + const result = await mergeAndCommit("upstream/release/2.0", { + cwd: ROOT, + runGit, + pathExists: () => false, + }); + + expect(result).toMatchObject({ + status: "committed", + target: { displayName: "upstream/release/2.0" }, + }); + expect( + calls.some( + ([command, noTags, noFetchHead, remote, refspec]) => + command === "fetch" && + noTags === "--no-tags" && + noFetchHead === "--no-write-fetch-head" && + remote === "upstream" && + refspec?.startsWith("refs/heads/release/2.0:"), + ), + ).toBe(true); + }); + + test("does not mutate a dirty worktree", async () => { + const { runGit, calls } = createRunner({ dirty: true }); + const result = await mergeAndCommit("main", { + cwd: ROOT, + runGit, + pathExists: () => false, + }); + + expect(result).toMatchObject({ + status: "blocked", + errorCode: "dirtyWorktree", + mayHaveSideEffects: false, + }); + expect(calls.some(([command]) => command === "fetch")).toBe(false); + }); + + test("rejects an option-like target before fetch", async () => { + const { runGit, calls } = createRunner(); + const result = await mergeAndCommit("--upload-pack=bad", { + cwd: ROOT, + runGit, + pathExists: () => false, + }); + + expect(result).toMatchObject({ + status: "blocked", + errorCode: "missingTargetBranch", + }); + expect(calls.some(([command]) => command === "fetch")).toBe(false); + }); + + test("returns conflicted paths for Reasoning and persists v1 state", async () => { + let stored: { path: string; content: string } | undefined; + const { runGit, calls } = createRunner({ + merge: fail("CONFLICT"), + conflicts: ["src/a.ts", "src/b.ts"], + stagedPaths: ["src/a.ts", "src/b.ts", "src/autoMerged.ts"], + }); + const result = await mergeAndCommit("main", { + cwd: ROOT, + runGit, + pathExists: () => false, + writeFile: (path, content) => { + stored = { path, content }; + }, + }); + + expect(result).toMatchObject({ + status: "conflicts", + conflicts: ["src/a.ts", "src/b.ts"], + }); + expect(calls.some(([command]) => command === "commit")).toBe(false); + expect(calls.some(([command]) => command === "push")).toBe(false); + + expect(stored).toBeDefined(); + expect(stored!.path).toContain("TYPEAGENT_MERGE_CONFLICTS"); + const parsed = JSON.parse(stored!.content); + expect(parsed).toEqual({ + version: 1, + head: HEAD_SHA, + mergeHead: MERGE_HEAD_SHA, + conflicts: ["src/a.ts", "src/b.ts"], + // Snapshot includes the auto-merged rename target that the + // previous diff-based reconstruction would have missed. + stagedPaths: ["src/a.ts", "src/b.ts", "src/autoMerged.ts"], + }); + }); + + test("cleans up the temporary fetch ref even when the merge fails", async () => { + const { runGit, calls } = createRunner({ + merge: fail("CONFLICT"), + conflicts: ["src/a.ts"], + }); + await mergeAndCommit("main", { + cwd: ROOT, + runGit, + pathExists: () => false, + writeFile: () => {}, + }); + expect( + calls.some( + ([command, flag, ref]) => + command === "update-ref" && + flag === "-d" && + ref?.startsWith("refs/typeagent/merge/"), + ), + ).toBe(true); + }); + + test("propagates AbortError when the signal is already aborted", async () => { + const controller = new AbortController(); + controller.abort(); + const { runGit, calls } = createRunner(); + await expect( + mergeAndCommit("main", { + cwd: ROOT, + runGit, + pathExists: () => false, + signal: controller.signal, + }), + ).rejects.toMatchObject({ name: "AbortError" }); + // Never even reached repo detection. + expect(calls.length).toBe(0); + }); + + test.each(["interrupted", "just completed"])( + "cancelling a %s fetch prevents merge and commit and cleans up", + async (fetchState) => { + const controller = new AbortController(); + const { runGit, calls } = createRunner(); + const cancelledRunner: GitCommandRunner = async ( + args, + cwd, + signal, + ) => { + if (args[0] === "fetch") { + expect(signal).toBe(controller.signal); + controller.abort(); + if (fetchState === "interrupted") { + throw new DOMException( + "The operation was aborted.", + "AbortError", + ); + } + return ok(); + } + if (args[0] === "update-ref" && args[1] === "-d") { + expect(signal).toBeUndefined(); + } + return runGit(args, cwd, signal); + }; + await expect( + mergeAndCommit("main", { + cwd: ROOT, + runGit: cancelledRunner, + pathExists: () => false, + signal: controller.signal, + }), + ).rejects.toMatchObject({ name: "AbortError" }); + expect(calls.some(([command]) => command === "merge")).toBe(false); + expect(calls.some(([command]) => command === "commit")).toBe(false); + expect( + calls.some( + ([command, flag]) => + command === "update-ref" && flag === "-d", + ), + ).toBe(true); + }, + ); + + test("reports failed ref cleanup without masking cancellation", async () => { + const controller = new AbortController(); + const { runGit } = createRunner(); + const originalWarn = console.warn; + const warnings: string[] = []; + console.warn = (message: string) => warnings.push(message); + try { + await expect( + mergeAndCommit("main", { + cwd: ROOT, + pathExists: () => false, + signal: controller.signal, + runGit: async (args, cwd, signal) => { + if (args[0] === "fetch") { + controller.abort(); + throw new DOMException("Cancelled", "AbortError"); + } + if (args[0] === "update-ref") { + expect(signal).toBeUndefined(); + return fail("cannot lock ref"); + } + return runGit(args, cwd, signal); + }, + }), + ).rejects.toMatchObject({ name: "AbortError" }); + expect(warnings).toEqual([ + expect.stringMatching( + /Could not remove temporary merge ref .*cannot lock ref/, + ), + ]); + } finally { + console.warn = originalWarn; + } + }); +}); + +describe("completeMergeConflictResolution", () => { + test("refuses to commit while conflicts remain", async () => { + const { runGit, calls } = createRunner({ + conflicts: ["src/a.ts"], + }); + const result = await completeMergeConflictResolution({ + cwd: ROOT, + runGit, + ...resolutionState, + }); + + expect(result).toMatchObject({ + status: "blocked", + errorCode: "unresolvedConflicts", + conflicts: ["src/a.ts"], + }); + expect(calls.some(([command]) => command === "commit")).toBe(false); + }); + + test("refuses to commit unstaged merge changes", async () => { + const { runGit } = createRunner({ unstaged: true }); + const result = await completeMergeConflictResolution({ + cwd: ROOT, + runGit, + ...resolutionState, + }); + expect(result).toMatchObject({ + status: "blocked", + errorCode: "unstagedChanges", + }); + }); + + test("refuses to commit staged changes unrelated to the merge", async () => { + const { runGit } = createRunner({ + stagedPaths: ["src/a.ts", "notes.txt"], + }); + const result = await completeMergeConflictResolution({ + cwd: ROOT, + runGit, + ...resolutionState, + }); + expect(result).toMatchObject({ + status: "blocked", + errorCode: "unrelatedChanges", + }); + }); + + test("refuses to commit remaining conflict markers", async () => { + const { runGit } = createRunner({ markers: true }); + const result = await completeMergeConflictResolution({ + cwd: ROOT, + runGit, + ...resolutionState, + }); + expect(result).toMatchObject({ + status: "blocked", + errorCode: "conflictMarkers", + }); + }); + + test("does not mistake incoming whitespace errors for conflict markers", async () => { + const { runGit } = createRunner({ whitespaceErrors: true }); + const result = await completeMergeConflictResolution({ + cwd: ROOT, + runGit, + ...resolutionState, + }); + expect(result).toMatchObject({ status: "committed" }); + }); + + test("commits a fully resolved and staged merge without pushing", async () => { + const { runGit, calls } = createRunner(); + const result = await completeMergeConflictResolution({ + cwd: ROOT, + runGit, + ...resolutionState, + }); + + expect(result).toMatchObject({ + status: "committed", + commit: HEAD_SHA, + }); + expect(calls).toContainEqual(["commit", "--no-edit"]); + expect(calls.some(([command]) => command === "push")).toBe(false); + }); + + test.each([ + ["legacy format", JSON.stringify(["src/a.ts"])], + ["invalid JSON", "not json"], + ["changed HEAD", stateFixture({ head: "different-commit" })], + ["changed MERGE_HEAD", stateFixture({ mergeHead: "different-commit" })], + ])("rejects resolution state with %s", async (_description, raw) => { + const { runGit, calls } = createRunner(); + const result = await completeMergeConflictResolution({ + cwd: ROOT, + runGit, + ...resolutionState, + readFile: () => raw, + }); + expect(result).toMatchObject({ + status: "blocked", + errorCode: "missingResolutionState", + mayHaveSideEffects: true, + }); + expect(calls.some(([command]) => command === "commit")).toBe(false); + }); + + test("surfaces a state-read failure instead of silently proceeding", async () => { + const { runGit, calls } = createRunner(); + const result = await completeMergeConflictResolution({ + cwd: ROOT, + runGit, + ...resolutionState, + readFile: () => { + throw new Error("EACCES"); + }, + }); + expect(result).toMatchObject({ + status: "blocked", + errorCode: "missingResolutionState", + mayHaveSideEffects: true, + }); + expect(result).toMatchObject({ + message: expect.stringContaining("EACCES"), + }); + expect(calls.some(([command]) => command === "commit")).toBe(false); + }); + + test("cancellation after index checks prevents committing or removing state", async () => { + const controller = new AbortController(); + const { runGit, calls } = createRunner(); + let removed = false; + await expect( + completeMergeConflictResolution({ + cwd: ROOT, + runGit: async (args, cwd, signal) => { + const result = await runGit(args, cwd, signal); + if (key(args) === key(["diff", "--cached", "--check"])) { + controller.abort(); + } + return result; + }, + ...resolutionState, + removeFile: () => { + removed = true; + }, + signal: controller.signal, + }), + ).rejects.toMatchObject({ name: "AbortError" }); + expect(calls.some(([command]) => command === "commit")).toBe(false); + expect(removed).toBe(false); + }); +}); + +describe("merge action results", () => { + test("queues verification after Reasoning instead of relying on model completion", () => { + const result = buildMergeResult({ + status: "conflicts", + repositoryRoot: ROOT, + currentBranch: "feature/work", + target: { + remote: "origin", + branch: "main", + displayName: "origin/main", + }, + conflicts: ["src/a.ts"], + }); + + expect(result.error).toBeUndefined(); + if (result.error !== undefined) { + throw new Error(result.error); + } + expect(result.additionalActions).toEqual([ + expect.objectContaining({ + schemaName: "dispatcher.reasoning", + actionName: "reasoningAction", + }), + { + schemaName: "github-cli", + actionName: "completeMergeConflictResolution", + parameters: { repositoryRoot: ROOT }, + }, + ]); + expect( + result.additionalActions?.[0].parameters?.originalRequest, + ).toContain(ROOT); + expect(result.additionalActions?.[0].parameters?.workingDirectory).toBe( + ROOT, + ); + }); + + test("handles grammar actions whose empty parameters were omitted", () => { + expect( + getRequestedMergeTarget({ actionName: "resolveMergeConflicts" }), + ).toBeUndefined(); + }); +}); diff --git a/ts/packages/agents/github-cli/test/mergeConflictIntegration.spec.ts b/ts/packages/agents/github-cli/test/mergeConflictIntegration.spec.ts new file mode 100644 index 0000000000..43495a8bbe --- /dev/null +++ b/ts/packages/agents/github-cli/test/mergeConflictIntegration.spec.ts @@ -0,0 +1,314 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { + ActionContext, + ActionIO, + SessionContext, + TypeAgentAction, +} from "@typeagent/agent-sdk"; +import type { GithubCliActions } from "../src/github-cliSchema.js"; +import { instantiate } from "../src/github-cliActionHandler.js"; +import { + completeMergeConflictResolution, + mergeAndCommit, +} from "../src/mergeConflict.js"; + +function gitAt(cwd: string, ...args: string[]): string { + return execFileSync("git", args, { + cwd, + encoding: "utf8", + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + }).trim(); +} + +function executeMergeAction( + action: TypeAgentAction, + workingDirectory?: string, + abortSignal?: AbortSignal, +) { + const context: ActionContext = { + get actionIO(): ActionIO { + throw new Error( + "Merge actions should return their display content", + ); + }, + get sessionContext(): SessionContext { + throw new Error( + "Merge actions should not require agent session state", + ); + }, + activityContext: undefined, + isFromReasoningLoop: false, + queueToggleTransientAgent: async () => {}, + streamingContext: undefined, + workingDirectory, + abortSignal, + }; + return instantiate().executeAction!(action, context); +} + +describe("merge conflict resolution with real Git", () => { + let repository: string; + const file = "source file.ts"; + const git = (...args: string[]) => gitAt(repository, ...args); + + beforeEach(() => { + repository = fs.mkdtempSync( + path.join(os.tmpdir(), "typeagent-merge-test-"), + ); + git("init", "-b", "main"); + git("config", "user.name", "Merge test"); + git("config", "user.email", "merge-test@example.invalid"); + git("config", "commit.gpgsign", "false"); + git("config", "core.autocrlf", "false"); + git("config", "core.hooksPath", path.join(repository, "no-hooks")); + fs.writeFileSync( + path.join(repository, file), + "export const base = 1;\n", + ); + git("add", "--", file); + git("commit", "-m", "base"); + git("checkout", "-b", "feature"); + fs.writeFileSync( + path.join(repository, file), + "export const ours = 1;\n", + ); + git("add", "--", file); + git("commit", "-m", "feature change"); + git("checkout", "main"); + fs.writeFileSync( + path.join(repository, file), + "export const theirs = 1;\n", + ); + git("add", "--", file); + git("commit", "-m", "main change"); + git("checkout", "feature"); + git("remote", "add", "origin", repository); + }); + + afterEach(() => { + fs.rmSync(repository, { recursive: true, force: true }); + }); + + test("leaves an incomplete resolution blocked, then verifies and commits both parents", async () => { + const before = git("rev-parse", "HEAD"); + const target = git("rev-parse", "main"); + const merged = await mergeAndCommit("main", { cwd: repository }); + expect(merged).toMatchObject({ + status: "conflicts", + conflicts: [file], + }); + expect(git("rev-parse", "HEAD")).toBe(before); + + // A model returning without editing must not count as completion. + expect( + await completeMergeConflictResolution({ cwd: repository }), + ).toMatchObject({ + status: "blocked", + errorCode: "unresolvedConflicts", + }); + expect(git("rev-parse", "HEAD")).toBe(before); + + fs.writeFileSync( + path.join(repository, file), + "export const ours = 1;\nexport const theirs = 1;\n", + ); + git("add", "--", file); + const completed = await completeMergeConflictResolution({ + cwd: repository, + }); + expect(completed).toMatchObject({ status: "committed" }); + expect(git("show", "-s", "--format=%P", "HEAD").split(" ")).toEqual([ + before, + target, + ]); + expect(git("status", "--porcelain")).toBe(""); + expect(fs.existsSync(path.join(repository, ".git", "MERGE_HEAD"))).toBe( + false, + ); + expect( + fs.existsSync( + path.join(repository, ".git", "TYPEAGENT_MERGE_CONFLICTS"), + ), + ).toBe(false); + }); + + test("rejects staged conflict markers even when Git considers the path resolved", async () => { + const before = git("rev-parse", "HEAD"); + await mergeAndCommit("main", { cwd: repository }); + git("add", "--", file); + expect(git("diff", "--name-only", "--diff-filter=U")).toBe(""); + expect( + await completeMergeConflictResolution({ cwd: repository }), + ).toMatchObject({ + status: "blocked", + errorCode: "conflictMarkers", + }); + expect(git("rev-parse", "HEAD")).toBe(before); + }); + + test("allows an auto-merged rename plus a separate conflict resolution", async () => { + // Git applies main's edit through the feature branch's rename. + const renamed = "src/renamed.ts"; + const original = "src/original.ts"; + const conflictFile = "src/conflict.txt"; + + git("checkout", "main"); + fs.mkdirSync(path.join(repository, "src"), { recursive: true }); + fs.writeFileSync( + path.join(repository, original), + "export const value = 1;\n", + ); + fs.writeFileSync(path.join(repository, conflictFile), "shared line\n"); + git("add", "--", original, conflictFile); + git("commit", "-m", "add rename base"); + + git("checkout", "-b", "rename-feature"); + fs.renameSync( + path.join(repository, original), + path.join(repository, renamed), + ); + fs.writeFileSync(path.join(repository, conflictFile), "feature line\n"); + git("add", "--all"); + git("commit", "-m", "feature rename"); + + git("checkout", "main"); + fs.writeFileSync( + path.join(repository, original), + "export const value = 2;\n", + ); + fs.writeFileSync(path.join(repository, conflictFile), "main line\n"); + git("add", "--", original, conflictFile); + git("commit", "-m", "main change"); + + git("checkout", "rename-feature"); + const before = git("rev-parse", "HEAD"); + + const started = await mergeAndCommit("main", { cwd: repository }); + expect(started).toMatchObject({ + status: "conflicts", + conflicts: [conflictFile], + }); + expect( + git("diff", "--cached", "--name-only", "HEAD").split("\n"), + ).toContain(renamed); + + fs.writeFileSync( + path.join(repository, conflictFile), + "feature line\nmain line\n", + ); + git("add", "--", conflictFile); + + const completed = await completeMergeConflictResolution({ + cwd: repository, + }); + expect(completed).toMatchObject({ status: "committed" }); + expect(git("status", "--porcelain")).toBe(""); + expect(git("rev-parse", "HEAD")).not.toBe(before); + expect(fs.existsSync(path.join(repository, ".git", "MERGE_HEAD"))).toBe( + false, + ); + expect( + fs.existsSync( + path.join(repository, ".git", "TYPEAGENT_MERGE_CONFLICTS"), + ), + ).toBe(false); + }); + + test("both actions honor the request repository rather than the server cwd or action parameter", async () => { + const request = fs.mkdtempSync( + path.join(os.tmpdir(), "typeagent-merge-request-"), + ); + const originalCwd = process.cwd(); + const serverHead = git("rev-parse", "HEAD"); + const requestGit = (...args: string[]) => gitAt(request, ...args); + try { + git("clone", "--quiet", repository, request); + requestGit("config", "user.name", "Merge test"); + requestGit("config", "user.email", "merge-test@example.invalid"); + requestGit("config", "commit.gpgsign", "false"); + requestGit( + "config", + "core.hooksPath", + path.join(request, "no-hooks"), + ); + process.chdir(repository); + expect( + await executeMergeAction( + { + schemaName: "github-cli", + actionName: "resolveMergeConflicts", + parameters: { targetBranch: "main" }, + }, + request, + ), + ).toMatchObject({ resultValue: { status: "conflicts" } }); + + fs.writeFileSync( + path.join(request, file), + "export const resolved = 1;\n", + ); + requestGit("add", "--", file); + expect( + await executeMergeAction( + { + schemaName: "github-cli", + actionName: "completeMergeConflictResolution", + parameters: { repositoryRoot: repository }, + }, + request, + ), + ).toMatchObject({ resultValue: { status: "committed" } }); + expect( + requestGit("show", "-s", "--format=%P", "HEAD").split(" "), + ).toEqual([serverHead, git("rev-parse", "main")]); + expect(requestGit("status", "--porcelain")).toBe(""); + expect(git("rev-parse", "HEAD")).toBe(serverHead); + expect(git("status", "--porcelain")).toBe(""); + expect( + fs.existsSync(path.join(repository, ".git", "MERGE_HEAD")), + ).toBe(false); + } finally { + process.chdir(originalCwd); + fs.rmSync(request, { recursive: true, force: true }); + } + }); + + test.each([ + { + actionName: "resolveMergeConflicts", + parameters: { targetBranch: "main" }, + }, + { + actionName: "completeMergeConflictResolution", + parameters: { repositoryRoot: "not-authorized" }, + }, + ] as const)( + "$actionName rejects missing request context and propagates cancellation", + async (action) => { + const requestAction = { ...action, schemaName: "github-cli" }; + expect(await executeMergeAction(requestAction)).toMatchObject({ + errorCode: "notRepository", + }); + const controller = new AbortController(); + controller.abort(); + await expect( + executeMergeAction( + requestAction, + repository, + controller.signal, + ), + ).rejects.toMatchObject({ name: "AbortError" }); + expect(git("status", "--porcelain")).toBe(""); + expect( + fs.existsSync(path.join(repository, ".git", "MERGE_HEAD")), + ).toBe(false); + }, + ); +}); diff --git a/ts/packages/dispatcher/dispatcher/README.md b/ts/packages/dispatcher/dispatcher/README.md index fc0f06c4a2..2b9fab7154 100644 --- a/ts/packages/dispatcher/dispatcher/README.md +++ b/ts/packages/dispatcher/dispatcher/README.md @@ -11,6 +11,22 @@ Dispatcher processes user requests and asks LLM to translate it into an action b See [dispatcher architecture](../../../docs/content/architecture/core/dispatcher.md) doc for more details on the design of the dispatcher component. +## Copilot SDK upgrade check + +After changing the Copilot SDK or native tool allowlist, run these commands +from `ts`: + +```powershell +pnpm --filter agent-dispatcher run tsc +pnpm --filter agent-dispatcher run test:live +``` + +This starts the installed Copilot runtime and checks the initialized session's +tools for Claude and GPT models, including platform-specific shell output and +cancellation tools. It sends no model prompt and executes no tools. Unlike the +unit tests, it detects names that no longer match the actual runtime. It requires +the SDK's bundled CLI to be available and able to start. + ## Usage - Natural Language Requests User can request actions provided by [application agents](../agentSdk/README.md) using natural language. diff --git a/ts/packages/dispatcher/dispatcher/package.json b/ts/packages/dispatcher/dispatcher/package.json index afa3408afd..104ed4d827 100644 --- a/ts/packages/dispatcher/dispatcher/package.json +++ b/ts/packages/dispatcher/dispatcher/package.json @@ -37,6 +37,7 @@ "prettier": "prettier --check . --ignore-path ../../../.prettierignore", "prettier:fix": "prettier --write . --ignore-path ../../../.prettierignore", "test": "npm run test:local", + "test:live": "pnpm run jest-esm --testPathPattern=\".*[.]test[.]js\"", "test:local": "pnpm run jest-esm --testPathPattern=\".*[.]spec[.]js\"", "test:local:debug": "node --inspect-brk --no-warnings --experimental-vm-modules ./node_modules/jest/bin/jest.js --testPathPattern=\".*\\.spec\\.js\"", "tsc": "tsc -b" diff --git a/ts/packages/dispatcher/dispatcher/src/context/dispatcher/schema/reasoningActionSchema.ts b/ts/packages/dispatcher/dispatcher/src/context/dispatcher/schema/reasoningActionSchema.ts index fd12be762e..c8e6bee18c 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/dispatcher/schema/reasoningActionSchema.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/dispatcher/schema/reasoningActionSchema.ts @@ -17,5 +17,8 @@ export interface ReasoningAction { attemptedAction?: string; // JSON-serialized entities on the stack at the time of redirect. contextEntities?: string; + // Override the tool working directory for a task tied to a specific + // local repository. Omit for the TypeAgent repository default. + workingDirectory?: string; }; } diff --git a/ts/packages/dispatcher/dispatcher/src/reasoning/claude.ts b/ts/packages/dispatcher/dispatcher/src/reasoning/claude.ts index 13e6afb1f5..8aa2a55dbc 100644 --- a/ts/packages/dispatcher/dispatcher/src/reasoning/claude.ts +++ b/ts/packages/dispatcher/dispatcher/src/reasoning/claude.ts @@ -491,6 +491,7 @@ function createClaudeCanUseTool(context: ActionContext) { function getClaudeOptions( context: ActionContext, + workingDirectory?: string, ): Options { const systemContext = context.sessionContext.agentContext; // Stable clientIO reference for get_user_context (see copilot.ts). @@ -1181,7 +1182,7 @@ function getClaudeOptions( // AVAILABILITY; `canUseTool` decides ALLOW/DENY per call. canUseTool: createClaudeCanUseTool(context), tools: availableBuiltInTools, - cwd: getRepoRoot(), + cwd: workingDirectory ?? getRepoRoot(), settingSources: [], maxTurns: 20, thinking: { type: "adaptive" }, @@ -1951,6 +1952,7 @@ async function executeReasoningWithoutPlanning( fallbackContext?: ReasoningFallbackContext, abortController?: AbortController, requireToolUse: boolean = false, + workingDirectory?: string, ): Promise { const abortSignal = abortController?.signal; // Display initial message @@ -1963,7 +1965,7 @@ async function executeReasoningWithoutPlanning( fallbackContext, ), options: { - ...getClaudeOptions(context), + ...getClaudeOptions(context, workingDirectory), ...claudeExecutableOption(), ...(abortController === undefined ? {} : { abortController }), }, @@ -2002,6 +2004,7 @@ async function executeReasoningWithTracing( fallbackContext?: ReasoningFallbackContext, abortController?: AbortController, requireToolUse: boolean = false, + workingDirectory?: string, ): Promise { const abortSignal = abortController?.signal; const systemContext = context.sessionContext.agentContext; @@ -2016,6 +2019,7 @@ async function executeReasoningWithTracing( undefined, abortController, requireToolUse, + workingDirectory, ); } @@ -2044,7 +2048,7 @@ async function executeReasoningWithTracing( fallbackContext, ), options: { - ...getClaudeOptions(context), + ...getClaudeOptions(context, workingDirectory), ...claudeExecutableOption(), ...(abortController === undefined ? {} : { abortController }), }, @@ -2292,6 +2296,9 @@ export async function executeReasoningAction( planReuseEnabled: planReuseEnabled || scriptReuseEnabled, engine: "claude", requireToolUse: true, + ...(action.parameters.workingDirectory === undefined + ? {} + : { workingDirectory: action.parameters.workingDirectory }), ...(fallbackContext ? { fallbackContext } : {}), }); } @@ -2664,6 +2671,7 @@ export async function executeReasoning( // (reasoningAction). Conversation-answer callers leave it false, since // answering a question with text only is a valid result. requireToolUse?: boolean; + workingDirectory?: string; }, ) { const engine = options?.engine ?? "claude"; @@ -2673,6 +2681,7 @@ export async function executeReasoning( const planReuseEnabled = options?.planReuseEnabled ?? false; const fallbackContext = options?.fallbackContext; const requireToolUse = options?.requireToolUse ?? false; + const workingDirectory = options?.workingDirectory; const controller = new AbortController(); return runInReasoningSpan( context, @@ -2685,6 +2694,7 @@ export async function executeReasoning( fallbackContext, controller, requireToolUse, + workingDirectory, ); } // Trace capture + auto recipe generation @@ -2694,6 +2704,7 @@ export async function executeReasoning( fallbackContext, controller, requireToolUse, + workingDirectory, ); }), { diff --git a/ts/packages/dispatcher/dispatcher/src/reasoning/copilot.ts b/ts/packages/dispatcher/dispatcher/src/reasoning/copilot.ts index 333dcbead3..f24a1e8870 100644 --- a/ts/packages/dispatcher/dispatcher/src/reasoning/copilot.ts +++ b/ts/packages/dispatcher/dispatcher/src/reasoning/copilot.ts @@ -672,8 +672,8 @@ function formatToolCallDisplay(toolName: string, input: unknown): string { return `**Tool:** \`remember\``; } - // Built-in tools (shell, github/fs/*, github/search/*, ...): show the - // primary argument so parallel or similar calls are distinguishable + // Built-in tools (view, edit, create, glob, grep, powershell, bash, ...): + // show the primary argument so parallel or similar calls are distinguishable // instead of rendering as identical "Tool: " bubbles. The tool name // is rendered as inline code so it reads as a highlighted chip (matching a // single tool call and the folded-batch summary). @@ -1306,12 +1306,67 @@ export async function executeCodingRequest( } } +// These are runtime tool names, not the obsolete github/fs/* or shell aliases. +// Restrict matches to built-ins; onPermissionRequest still authorizes each call. +export const COPILOT_NATIVE_BUILTIN_TOOLS: readonly string[] = [ + "builtin:view", + "builtin:edit", + "builtin:create", + "builtin:apply_patch", + "builtin:str_replace_editor", + "builtin:glob", + "builtin:grep", + "builtin:rg", + "builtin:powershell", + "builtin:read_powershell", + "builtin:stop_powershell", + "builtin:list_powershell", + "builtin:bash", + "builtin:read_bash", + "builtin:stop_bash", + "builtin:list_bash", + "builtin:web_fetch", +]; + +const COPILOT_ALWAYS_ON_CUSTOM_TOOLS: readonly string[] = [ + "discover_actions", + "execute_action", + "search_memory", + "remember", + "get_conversation_info", + "read_conversation", + "list_conversations", + "search_conversations", + "get_user_context", + "find_installable_agent", + "ask_user", + "ask_user_form", +]; + +const COPILOT_SUBAGENT_CUSTOM_TOOLS: readonly string[] = [ + "create_subagent", + "invoke_subagent", + "list_subagents", + "stop_subagent", +]; + +export function buildCopilotAvailableTools(opts: { + subagentsEnabled: boolean; +}): string[] { + return [ + ...COPILOT_ALWAYS_ON_CUSTOM_TOOLS, + ...(opts.subagentsEnabled ? COPILOT_SUBAGENT_CUSTOM_TOOLS : []), + ...COPILOT_NATIVE_BUILTIN_TOOLS, + ]; +} + /** - * Get Copilot SDK session configuration with TypeAgent tools - * (Mirrors getClaudeOptions from claude.ts) + * Get Copilot SDK session configuration with TypeAgent tools. + * Mirrors getClaudeOptions from claude.ts. */ function getCopilotSessionConfig( context: ActionContext, + workingDirectory?: string, ): SessionConfig { const systemContext = context.sessionContext.agentContext; // Capture the request's clientIO now, before execute_action transiently @@ -2117,32 +2172,8 @@ function getCopilotSessionConfig( askUserTool, askUserFormTool, ], - availableTools: [ - "discover_actions", - "execute_action", - "search_memory", - "remember", - "get_conversation_info", - "read_conversation", - "list_conversations", - "search_conversations", - "get_user_context", - ...(subagentsEnabled - ? [ - "create_subagent", - "invoke_subagent", - "list_subagents", - "stop_subagent", - ] - : []), - "find_installable_agent", - "ask_user", - "ask_user_form", - "github/fs/*", - "github/search/*", - "shell", - ], - workingDirectory: getRepoRoot(), + availableTools: buildCopilotAvailableTools({ subagentsEnabled }), + workingDirectory: workingDirectory ?? getRepoRoot(), onPermissionRequest: createCopilotPermissionHandler(context), systemMessage: { mode: "append" as const, @@ -2154,9 +2185,11 @@ function getCopilotSessionConfig( "## Built-in Tools (USE THESE FIRST)", "You have access to powerful built-in capabilities:", "- **Web search**: Use your native web search for looking up information online", - "- **File operations**: `github/fs/*` for reading, writing, editing files", - "- **Code search**: `github/search/*` for searching code patterns", - "- **Shell commands**: `shell` for executing terminal commands", + "- **File reading**: `view` for reading a file (whole file or a line range) or listing a directory", + "- **File search**: `glob` for finding files by name; `grep` or `rg` for searching contents, depending on the model's available tools", + "- **File editing**: Use the available native editor: `edit` / `create`, `apply_patch`, or `str_replace_editor`", + "- **Shell commands**: `powershell` on Windows or `bash` on macOS/Linux; use the matching `read_*`, `stop_*`, and `list_*` tools for commands that continue running", + "- **Web pages**: `web_fetch` for retrieving a URL", "", "## TypeAgent Action Tools (USE WHEN NEEDED)", "For TypeAgent-specific actions like music playback, calendar management, email:", @@ -2201,7 +2234,7 @@ function getCopilotSessionConfig( "- **PREFER built-in tools** for web search, file operations, and code investigation", "- **Use TypeAgent actions** only for domain-specific operations (music, calendar, email, etc.)", "- For web search queries → use your native web search capability", - "- For code operations → use `github/fs/*` and `github/search/*` tools", + "- For code operations → use the available native file, search, and terminal tools directly", "- For TypeAgent capabilities → use `discover_actions` then `execute_action`", ].join("\n"), }, @@ -2273,6 +2306,7 @@ async function createCopilotSession( async function executeReasoningWithoutPlanning( originalRequest: string, context: ActionContext, + workingDirectory?: string, ): Promise { debug(`Executing reasoning request: ${originalRequest}`); context.actionIO.appendDisplay("Thinking...", "temporary"); @@ -2299,7 +2333,7 @@ async function executeReasoningWithoutPlanning( let copilotToolLoopIteration = 0; const client = await getCopilotClient(context.sessionContext.agentContext); - const config = getCopilotSessionConfig(context); + const config = getCopilotSessionConfig(context, workingDirectory); // Check for existing session ID to enable multi-turn conversations let sessionId = getSessionId(context); @@ -2605,13 +2639,18 @@ async function executeReasoningWithoutPlanning( async function executeReasoningWithTracing( originalRequest: string, context: ActionContext, + workingDirectory?: string, ): Promise { const systemContext = context.sessionContext.agentContext; const storage = context.sessionContext.sessionStorage; if (!storage) { debug("No sessionStorage available, using standard reasoning"); - return executeReasoningWithoutPlanning(originalRequest, context); + return executeReasoningWithoutPlanning( + originalRequest, + context, + workingDirectory, + ); } const requestId = generateRequestId(); @@ -2654,7 +2693,7 @@ async function executeReasoningWithTracing( const client = await getCopilotClient( context.sessionContext.agentContext, ); - const config = getCopilotSessionConfig(context); + const config = getCopilotSessionConfig(context, workingDirectory); // Check for existing session ID to enable multi-turn conversations let sessionId = getSessionId(context); @@ -3150,6 +3189,9 @@ export async function executeReasoningAction( return executeReasoning(request, context, { planReuseEnabled, engine: "copilot", + ...(action.parameters.workingDirectory === undefined + ? {} + : { workingDirectory: action.parameters.workingDirectory }), }); } @@ -3163,6 +3205,7 @@ export async function executeReasoning( options?: { planReuseEnabled?: boolean; engine?: "copilot"; + workingDirectory?: string; }, ): Promise { const engine = options?.engine ?? "copilot"; @@ -3177,10 +3220,18 @@ export async function executeReasoning( () => { if (!planReuseEnabled) { // Standard reasoning without planning - return executeReasoningWithoutPlanning(request, context); + return executeReasoningWithoutPlanning( + request, + context, + options?.workingDirectory, + ); } // Trace capture + auto recipe generation - return executeReasoningWithTracing(request, context); + return executeReasoningWithTracing( + request, + context, + options?.workingDirectory, + ); }, { genAiSystem: "github_copilot", diff --git a/ts/packages/dispatcher/dispatcher/test/copilotAvailableTools.spec.ts b/ts/packages/dispatcher/dispatcher/test/copilotAvailableTools.spec.ts new file mode 100644 index 0000000000..83e4b7e55c --- /dev/null +++ b/ts/packages/dispatcher/dispatcher/test/copilotAvailableTools.spec.ts @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + buildCopilotAvailableTools, + COPILOT_NATIVE_BUILTIN_TOOLS, +} from "../src/reasoning/copilot.js"; + +describe("Copilot availableTools allowlist", () => { + it("uses source-qualified `builtin:` filters so only host-registered built-ins match", () => { + for (const entry of COPILOT_NATIVE_BUILTIN_TOOLS) { + expect(entry.startsWith("builtin:")).toBe(true); + const name = entry.slice("builtin:".length); + expect(name).toMatch(/^[a-zA-Z0-9_-]+$|^\*$/); + } + }); + + it("advertises the native tools the merge-conflict resolution action depends on", () => { + expect(COPILOT_NATIVE_BUILTIN_TOOLS).toEqual( + expect.arrayContaining([ + "builtin:view", + "builtin:edit", + "builtin:create", + "builtin:glob", + "builtin:grep", + ]), + ); + expect(COPILOT_NATIVE_BUILTIN_TOOLS).toEqual( + expect.arrayContaining(["builtin:powershell", "builtin:bash"]), + ); + }); + + it("does not include obsolete `github/fs/*`, `github/search/*`, or bare `shell` patterns", () => { + for (const obsolete of [ + "github/fs/*", + "github/search/*", + "shell", + "builtin:github/fs/*", + "builtin:github/search/*", + "builtin:shell", + ]) { + expect(COPILOT_NATIVE_BUILTIN_TOOLS).not.toContain(obsolete); + } + }); + + it("includes model-specific editors and shell output and cancellation tools", () => { + expect(COPILOT_NATIVE_BUILTIN_TOOLS).toEqual( + expect.arrayContaining([ + "builtin:apply_patch", + "builtin:str_replace_editor", + "builtin:rg", + "builtin:read_powershell", + "builtin:stop_powershell", + "builtin:list_powershell", + "builtin:read_bash", + "builtin:stop_bash", + "builtin:list_bash", + "builtin:web_fetch", + ]), + ); + }); + + it("composes custom tools, subagent tools (when enabled), and native built-ins", () => { + const withoutSubagents = buildCopilotAvailableTools({ + subagentsEnabled: false, + }); + const withSubagents = buildCopilotAvailableTools({ + subagentsEnabled: true, + }); + + for (const custom of [ + "discover_actions", + "execute_action", + "search_memory", + "get_user_context", + "ask_user", + "ask_user_form", + "find_installable_agent", + ]) { + expect(withoutSubagents).toContain(custom); + expect(withSubagents).toContain(custom); + } + + for (const builtin of COPILOT_NATIVE_BUILTIN_TOOLS) { + expect(withoutSubagents).toContain(builtin); + expect(withSubagents).toContain(builtin); + } + + const subagentTools = [ + "create_subagent", + "invoke_subagent", + "list_subagents", + "stop_subagent", + ]; + for (const s of subagentTools) { + expect(withoutSubagents).not.toContain(s); + expect(withSubagents).toContain(s); + } + }); + + it("returns a fresh array each call so callers cannot mutate the source constants", () => { + const first = buildCopilotAvailableTools({ subagentsEnabled: true }); + first.pop(); + const second = buildCopilotAvailableTools({ subagentsEnabled: true }); + expect(second.length).toBeGreaterThan(first.length); + }); +}); diff --git a/ts/packages/dispatcher/dispatcher/test/copilotAvailableTools.test.ts b/ts/packages/dispatcher/dispatcher/test/copilotAvailableTools.test.ts new file mode 100644 index 0000000000..fdeffc74e5 --- /dev/null +++ b/ts/packages/dispatcher/dispatcher/test/copilotAvailableTools.test.ts @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import fs from "node:fs"; +import { createRequire } from "node:module"; +import os from "node:os"; +import path from "node:path"; +import { buildCopilotAvailableTools } from "../src/reasoning/copilot.js"; + +// Jest's ESM VM lacks import.meta.resolve; use the SDK's CommonJS entry so its +// bundled CLI resolver can use Node's native module resolution. +const { CopilotClient }: typeof import("@github/copilot-sdk") = createRequire( + import.meta.url, +)("@github/copilot-sdk"); + +describe("installed Copilot runtime tool contract", () => { + const client = new CopilotClient(); + let workingDirectory: string; + + beforeAll(async () => { + workingDirectory = fs.mkdtempSync( + path.join(os.tmpdir(), "typeagent-tool-contract-"), + ); + await client.start(); + }); + + afterAll(async () => { + try { + expect(await client.stop()).toEqual([]); + } finally { + fs.rmSync(workingDirectory, { recursive: true, force: true }); + } + }); + + test.each([ + ["claude-opus-4.8", ["view", "edit", "create", "grep"]], + ["gpt-5.4", ["view", "apply_patch", "rg"]], + ] as const)("%s exposes usable native tools", async (model, editors) => { + const session = await client.createSession({ + model, + workingDirectory, + availableTools: buildCopilotAvailableTools({ + subagentsEnabled: false, + }), + onPermissionRequest: () => ({ + kind: "denied-no-approval-rule-and-could-not-request-from-user", + }), + }); + try { + // Unlike the static global listing, this applies the host platform, + // model overrides and our allowlist. No model request is sent. + await session.rpc.tools.initializeAndValidate(); + const { tools } = await session.rpc.tools.getCurrentMetadata(); + expect(tools).not.toBeNull(); + const names = tools?.map((tool) => tool.name); + const shell = process.platform === "win32" ? "powershell" : "bash"; + expect(names).toEqual( + expect.arrayContaining([ + ...editors, + "glob", + "web_fetch", + shell, + `read_${shell}`, + `stop_${shell}`, + `list_${shell}`, + ]), + ); + for (const excluded of ["task", "run_factory", "skill"]) { + expect(names).not.toContain(excluded); + } + } finally { + await client.deleteSession(session.sessionId); + } + }); +});