fix: improve minimax apply-patch compatibility
This commit is contained in:
@@ -11,6 +11,7 @@ export interface UpdateChunk {
|
||||
sourceStartLine: number | null;
|
||||
oldLines: string[];
|
||||
newLines: string[];
|
||||
contextLinePairs: Array<{ oldIndex: number; newIndex: number }>;
|
||||
isEndOfFile: boolean;
|
||||
}
|
||||
|
||||
@@ -120,9 +121,9 @@ export function applyPatchV2HelpPayload() {
|
||||
rules: [
|
||||
"Add File has no @@ hunk marker: put content immediately after `*** Add File: <path>` and prefix every content line with +.",
|
||||
"A blank line in Add File is a line containing only +.",
|
||||
"Update File uses @@ or @@ context markers, followed by context lines starting with space and changed lines starting with - or +; unified-diff line-range headers are accepted with hints for MiniMax compatibility.",
|
||||
"Update File uses @@ or @@ context markers, followed by context lines starting with one extra space prefix and changed lines starting with - or +; for a column-0 source line `const x`, write ` const x`, and for a two-space-indented source line write three spaces total. Unified-diff line-range headers are accepted with hints for MiniMax compatibility.",
|
||||
"Prefer `trans <route> apply-patch < /tmp/patch.diff` for long patches, Windows paths, or quoting-sensitive content.",
|
||||
"MiniMax compatibility: stray @@ or unprefixed content inside Add File, and extra hunk/body lines after Delete File, are accepted with stderr hints."
|
||||
"MiniMax compatibility: stray @@ or unprefixed content inside Add File, unprefixed Update File context lines, and extra hunk/body lines after Delete File, are accepted with stderr hints."
|
||||
],
|
||||
examples: {
|
||||
addFile: [
|
||||
@@ -394,6 +395,7 @@ function formatApplyPatchFailure(error: unknown): string {
|
||||
const details = error instanceof ApplyPatchV2Error ? error.details : {};
|
||||
const outcomes = Array.isArray(details.outcomes) ? details.outcomes as ApplyPatchV2Outcome[] : [];
|
||||
const lines = [`${message.trimEnd()}`];
|
||||
appendExpectedLinesFailureHint(lines, details);
|
||||
if (outcomes.length > 1 || outcomes.some((item) => item.status === "applied")) {
|
||||
lines.push("Patch status:");
|
||||
appendOutcomeSection(lines, "Applied before failure:", outcomes.filter((item) => item.status === "applied"));
|
||||
@@ -402,6 +404,37 @@ function formatApplyPatchFailure(error: unknown): string {
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
function appendExpectedLinesFailureHint(lines: string[], details: Record<string, unknown>): void {
|
||||
const cause = recordValue(details.cause) ?? details;
|
||||
const expected = typeof cause.expected === "string" ? cause.expected : "";
|
||||
if (expected.length === 0) return;
|
||||
const path = typeof cause.path === "string" ? cause.path : "target file";
|
||||
const chunk = typeof cause.chunk === "number" ? ` hunk ${cause.chunk}` : "";
|
||||
lines.push(`Expected lines for ${path}${chunk}:`);
|
||||
appendQuotedBlock(lines, expected, 20, 1600);
|
||||
lines.push("Hint: re-read the target file around this hunk. In Update File hunks, every context line needs a leading space prefix; for a column-0 source line like `]);`, write ` ]);`.");
|
||||
}
|
||||
|
||||
function recordValue(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : null;
|
||||
}
|
||||
|
||||
function appendQuotedBlock(lines: string[], text: string, maxLines: number, maxChars: number): void {
|
||||
const sourceLines = text.split("\n");
|
||||
let usedChars = 0;
|
||||
let omitted = 0;
|
||||
for (const [index, line] of sourceLines.entries()) {
|
||||
const quoted = JSON.stringify(line);
|
||||
if (index >= maxLines || usedChars + quoted.length > maxChars) {
|
||||
omitted = sourceLines.length - index;
|
||||
break;
|
||||
}
|
||||
lines.push(` ${quoted}`);
|
||||
usedChars += quoted.length;
|
||||
}
|
||||
if (omitted > 0) lines.push(` ... ${omitted} more expected line(s) omitted`);
|
||||
}
|
||||
|
||||
function appendOutcomeSection(lines: string[], title: string, outcomes: ApplyPatchV2Outcome[]): void {
|
||||
if (outcomes.length === 0) return;
|
||||
lines.push(title);
|
||||
@@ -806,6 +839,14 @@ function isFileHeader(line: string): boolean {
|
||||
return trimmed.startsWith(addFileMarker) || trimmed.startsWith(deleteFileMarker) || trimmed.startsWith(updateFileMarker) || trimmed === endMarker;
|
||||
}
|
||||
|
||||
function isUpdateChunkHeader(line: string): boolean {
|
||||
return line === emptyChangeContextMarker || line.startsWith(changeContextMarker) || parseUnifiedDiffHunkHeader(line) !== null;
|
||||
}
|
||||
|
||||
function pushUniqueHint(hints: string[], prefix: string, hint: string): void {
|
||||
if (!hints.some((existing) => existing.startsWith(prefix))) hints.push(hint);
|
||||
}
|
||||
|
||||
function parseUpdateChunk(lines: string[], startIndex: number, allowMissingContext: boolean, filePath: string, hints: string[]): { chunk: UpdateChunk; nextIndex: number } {
|
||||
let index = startIndex;
|
||||
let changeContext: string | null = null;
|
||||
@@ -829,11 +870,18 @@ function parseUpdateChunk(lines: string[], startIndex: number, allowMissingConte
|
||||
|
||||
const oldLines: string[] = [];
|
||||
const newLines: string[] = [];
|
||||
const contextLinePairs: Array<{ oldIndex: number; newIndex: number }> = [];
|
||||
let parsed = 0;
|
||||
let isEndOfFile = false;
|
||||
function pushContextLine(value: string): void {
|
||||
contextLinePairs.push({ oldIndex: oldLines.length, newIndex: newLines.length });
|
||||
oldLines.push(value);
|
||||
newLines.push(value);
|
||||
}
|
||||
while (index < lines.length - 1) {
|
||||
const line = lines[index] ?? "";
|
||||
if (isFileHeader(line)) break;
|
||||
if (parsed > 0 && isUpdateChunkHeader(line)) break;
|
||||
if (line === eofMarker) {
|
||||
if (parsed === 0) throw new ApplyPatchV2Error("update chunk does not contain any lines", { line: index + 1 });
|
||||
isEndOfFile = true;
|
||||
@@ -842,25 +890,26 @@ function parseUpdateChunk(lines: string[], startIndex: number, allowMissingConte
|
||||
}
|
||||
const marker = line[0] ?? "";
|
||||
if (marker === " ") {
|
||||
oldLines.push(line.slice(1));
|
||||
newLines.push(line.slice(1));
|
||||
pushContextLine(line.slice(1));
|
||||
} else if (marker === "+") {
|
||||
newLines.push(line.slice(1));
|
||||
} else if (marker === "-") {
|
||||
oldLines.push(line.slice(1));
|
||||
} else if (line.length === 0) {
|
||||
oldLines.push("");
|
||||
newLines.push("");
|
||||
} else if (parsed > 0) {
|
||||
break;
|
||||
pushContextLine("");
|
||||
} else {
|
||||
throw new ApplyPatchV2Error("unexpected line in update chunk", { line: index + 1, text: line });
|
||||
pushUniqueHint(
|
||||
hints,
|
||||
`apply-patch hint: accepted unprefixed Update File context line in ${filePath}`,
|
||||
`apply-patch hint: accepted unprefixed Update File context line in ${filePath} on line ${index + 1}; prefix context lines with one extra space in addition to source indentation.`,
|
||||
);
|
||||
pushContextLine(line);
|
||||
}
|
||||
parsed += 1;
|
||||
index += 1;
|
||||
}
|
||||
if (parsed === 0) throw new ApplyPatchV2Error("update chunk does not contain any lines", { line: startIndex + 1 });
|
||||
return { chunk: { changeContext, sourceStartLine, oldLines, newLines, isEndOfFile }, nextIndex: index };
|
||||
return { chunk: { changeContext, sourceStartLine, oldLines, newLines, contextLinePairs, isEndOfFile }, nextIndex: index };
|
||||
}
|
||||
|
||||
function parseUnifiedDiffHunkHeader(line: string): { oldStart: number } | null {
|
||||
@@ -915,13 +964,24 @@ function computeReplacements(filePath: string, originalLines: string[], chunks:
|
||||
expected: chunk.oldLines.join("\n"),
|
||||
});
|
||||
}
|
||||
replacements.push([found, pattern.length, newLines]);
|
||||
replacements.push([found, pattern.length, preserveMatchedContextLines(originalLines, found, newLines, chunk.contextLinePairs, pattern.length)]);
|
||||
lineIndex = found + pattern.length;
|
||||
}
|
||||
assertNonOverlappingReplacements(filePath, replacements, originalLines.length);
|
||||
return replacements;
|
||||
}
|
||||
|
||||
function preserveMatchedContextLines(originalLines: string[], found: number, newLines: string[], contextLinePairs: UpdateChunk["contextLinePairs"], matchedOldLength: number): string[] {
|
||||
if (contextLinePairs.length === 0) return newLines;
|
||||
const result = [...newLines];
|
||||
for (const pair of contextLinePairs) {
|
||||
if (pair.oldIndex >= matchedOldLength || pair.newIndex >= result.length) continue;
|
||||
const originalLine = originalLines[found + pair.oldIndex];
|
||||
if (originalLine !== undefined) result[pair.newIndex] = originalLine;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function seekSequenceWithFallback(lines: string[], pattern: string[], preferredStart: number, fallbackStart: number, eof: boolean): number | null {
|
||||
const preferred = seekSequence(lines, pattern, preferredStart, eof);
|
||||
if (preferred !== null || preferredStart === fallbackStart) return preferred;
|
||||
|
||||
@@ -11,9 +11,11 @@ import {
|
||||
formatSshFailureHint,
|
||||
formatSshRuntimeTimeoutHint,
|
||||
formatSshRuntimeTimingHint,
|
||||
normalizeSshOperationArgs,
|
||||
parseSshInvocation,
|
||||
remoteCommandForRoute,
|
||||
sshFailureHint,
|
||||
sshRouteSeparatorCompatibilityHint,
|
||||
sshRoutePayloadCwd,
|
||||
sshRuntimeTimeoutHint,
|
||||
sshRuntimeTimeoutMs,
|
||||
@@ -1276,21 +1278,23 @@ export function remoteSshFrontendPlanForTest(target: string, args: string[]): Re
|
||||
|
||||
async function runRemoteSshOverFrontend(session: FrontendSession, target: string | undefined, args: string[]): Promise<number> {
|
||||
if (!target) throw new Error("remote ssh requires a route, for example: bun scripts/cli.ts --main-server-ip 74.48.78.17 ssh D601 hostname");
|
||||
const invocation = parseSshInvocation(target, args);
|
||||
if (isSshFileTransferOperation(args)) {
|
||||
const normalizedArgs = normalizeSshOperationArgs(args);
|
||||
process.stderr.write(sshRouteSeparatorCompatibilityHint(args, normalizedArgs));
|
||||
const invocation = parseSshInvocation(target, normalizedArgs);
|
||||
if (isSshFileTransferOperation(normalizedArgs)) {
|
||||
const executor: SshRemoteCommandExecutor = {
|
||||
runRemoteCommand: (remoteCommand, input) => runRemoteSshWebSocketCaptureRemoteCommand(session, invocation, remoteCommand, input),
|
||||
};
|
||||
return await runSshFileTransferOperation(invocation, args, executor, {
|
||||
return await runSshFileTransferOperation(invocation, normalizedArgs, executor, {
|
||||
buildRouteCommand: remoteCommandForRoute,
|
||||
buildWindowsPowerShellCommand: buildWindowsPowerShellInvocation,
|
||||
});
|
||||
}
|
||||
if ((args[0] ?? "") === "apply-patch") {
|
||||
if ((normalizedArgs[0] ?? "") === "apply-patch") {
|
||||
const executor: ApplyPatchV2Executor = {
|
||||
run: (command, input) => runRemoteSshWebSocketCapture(session, invocation, command, input),
|
||||
};
|
||||
return await runApplyPatchV2({ executor, stdin: process.stdin, stdout: process.stdout, stderr: process.stderr, argv: args.slice(1) });
|
||||
return await runApplyPatchV2({ executor, stdin: process.stdin, stdout: process.stdout, stderr: process.stderr, argv: normalizedArgs.slice(1) });
|
||||
}
|
||||
return runRemoteSshWebSocket(session, invocation);
|
||||
}
|
||||
|
||||
+24
-10
@@ -911,6 +911,17 @@ export function parseSshArgs(args: string[]): ParsedSshArgs {
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeSshOperationArgs(args: string[]): string[] {
|
||||
return args[0] === "--" ? args.slice(1) : args;
|
||||
}
|
||||
|
||||
export function sshRouteSeparatorCompatibilityHint(rawArgs: string[], normalizedArgs: string[]): string {
|
||||
if (rawArgs === normalizedArgs || rawArgs[0] !== "--") return "";
|
||||
const operation = normalizedArgs[0] ?? "";
|
||||
const operationText = operation.length === 0 ? "operation" : `operation \`${operation}\``;
|
||||
return `UNIDESK_SSH_HINT route-level -- is ignored before ${operationText}; canonical form is \`trans <route> ${normalizedArgs.join(" ")}\`. Keep -- only inside operations such as \`script -- <command>\` or \`exec -- <command>\`.\n`;
|
||||
}
|
||||
|
||||
function validateDirectArgvCommand(commandName: string, toolArgs: string[]): void {
|
||||
if (toolArgs.length !== 1) return;
|
||||
const command = toolArgs[0] ?? "";
|
||||
@@ -927,16 +938,17 @@ function looksLikeShellCommandString(value: string): boolean {
|
||||
|
||||
export function parseSshInvocation(target: string, args: string[]): ParsedSshInvocation {
|
||||
const route = parseSshRoute(target);
|
||||
const operationArgs = normalizeSshOperationArgs(args);
|
||||
if (route.plane === "k3s") {
|
||||
return { providerId: route.providerId, route, parsed: parseK3sRouteArgs(route, args) };
|
||||
return { providerId: route.providerId, route, parsed: parseK3sRouteArgs(route, operationArgs) };
|
||||
}
|
||||
if (route.plane === "win") {
|
||||
return { providerId: route.providerId, route, parsed: parseWinRouteArgs(route, args) };
|
||||
return { providerId: route.providerId, route, parsed: parseWinRouteArgs(route, operationArgs) };
|
||||
}
|
||||
if ((args[0] ?? "") === "k3s") {
|
||||
throw new Error(`ssh k3s shorthand is unsupported; use route syntax instead: trans ${route.providerId}:k3s ${args.slice(1).join(" ")}`.trim());
|
||||
if ((operationArgs[0] ?? "") === "k3s") {
|
||||
throw new Error(`ssh k3s shorthand is unsupported; use route syntax instead: trans ${route.providerId}:k3s ${operationArgs.slice(1).join(" ")}`.trim());
|
||||
}
|
||||
return { providerId: route.providerId, route, parsed: parseSshArgs(args) };
|
||||
return { providerId: route.providerId, route, parsed: parseSshArgs(operationArgs) };
|
||||
}
|
||||
|
||||
export function parseSshRoute(target: string): ParsedSshRoute {
|
||||
@@ -2512,14 +2524,16 @@ function writeChunkedStdin(stdin: NodeJS.WritableStream, input: string): void {
|
||||
}
|
||||
|
||||
export async function runSsh(config: UniDeskConfig, providerId: string, args: string[]): Promise<number> {
|
||||
const invocation = parseSshInvocation(providerId, args);
|
||||
const normalizedArgs = normalizeSshOperationArgs(args);
|
||||
process.stderr.write(sshRouteSeparatorCompatibilityHint(args, normalizedArgs));
|
||||
const invocation = parseSshInvocation(providerId, normalizedArgs);
|
||||
const parsed = invocation.parsed;
|
||||
const operationName = args[0] ?? "";
|
||||
if (isSshFileTransferOperation(args)) {
|
||||
const operationName = normalizedArgs[0] ?? "";
|
||||
if (isSshFileTransferOperation(normalizedArgs)) {
|
||||
const executor: SshRemoteCommandExecutor = {
|
||||
runRemoteCommand: (remoteCommand, input) => runSshCaptureRemoteCommand(config, invocation, remoteCommand, input),
|
||||
};
|
||||
return await runSshFileTransferOperation(invocation, args, executor, {
|
||||
return await runSshFileTransferOperation(invocation, normalizedArgs, executor, {
|
||||
buildRouteCommand: remoteCommandForRoute,
|
||||
buildWindowsPowerShellCommand: buildWindowsPowerShellInvocation,
|
||||
});
|
||||
@@ -2533,7 +2547,7 @@ export async function runSsh(config: UniDeskConfig, providerId: string, args: st
|
||||
stdin: process.stdin,
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
argv: args.slice(1),
|
||||
argv: normalizedArgs.slice(1),
|
||||
});
|
||||
}
|
||||
const startedAtMs = Date.now();
|
||||
|
||||
Reference in New Issue
Block a user