Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions ts/packages/agents/github-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down
123 changes: 123 additions & 0 deletions ts/packages/agents/github-cli/src/github-cliActionHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -463,6 +468,12 @@ export function buildArgs(
const p = action.parameters as Record<string, unknown>;

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"];
Expand Down Expand Up @@ -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<MergeConflictResult, { status: "blocked" }>,
): 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<GithubCliActions>,
context: ActionContext<unknown>,
): Promise<ActionResult> {
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(
Expand All @@ -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
Expand Down
22 changes: 22 additions & 0 deletions ts/packages/agents/github-cli/src/github-cliSchema.agr
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,27 @@
}
};

<ResolveMergeConflicts> = 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";

<Start> : GithubCliActions = <AuthLogin>
Expand Down Expand Up @@ -576,4 +597,5 @@ import { GithubCliActions } from "./github-cliSchema.ts";
| <GistList>
| <ReleaseList>
| <DependabotAlerts>
| <ResolveMergeConflicts>
| <ApiRequest>;
34 changes: 33 additions & 1 deletion ts/packages/agents/github-cli/src/github-cliSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,9 @@ export type GithubCliActions =
| MyPullRequestsAction
| IssueAddLabelAction
| VariableCreateAction
| DependabotAlertsAction;
| DependabotAlertsAction
| ResolveMergeConflictsAction
| CompleteMergeConflictResolutionAction;

export type AuthLoginAction = {
actionName: "authLogin";
Expand Down Expand Up @@ -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;
};
};
Loading
Loading