feat(FN-1588): add truncated-prompt retry for merger context-limit recovery
- Detect when merge prompt exceeds context limit and gets truncated - Retry merge with compacted prompt when truncation is detected - Create fresh session after context limit recovery - Add comprehensive tests for truncation detection and recovery flow - Update merge prompt generation to include truncation signal
This commit is contained in:
@@ -3118,3 +3118,315 @@ describe("aiMergeTask — fresh session and compaction recovery", () => {
|
||||
expect(mergerModule).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Merge Prompt Truncation Tests ─────────────────────────────────────
|
||||
|
||||
describe("buildMergePrompt — truncation behavior", () => {
|
||||
it("truncates commit log when exceeding MERGE_COMMIT_LOG_MAX_CHARS", async () => {
|
||||
const { buildMergePrompt } = await import("./merger.js");
|
||||
|
||||
// Create a commit log that exceeds 5000 characters
|
||||
const longCommitLog = "- " + "a".repeat(6000);
|
||||
const prompt = buildMergePrompt({
|
||||
taskId: "FN-001",
|
||||
branch: "fusion/fn-001",
|
||||
commitLog: longCommitLog,
|
||||
diffStat: "1 file changed",
|
||||
hasConflicts: false,
|
||||
});
|
||||
|
||||
// The prompt should contain truncation indicator
|
||||
expect(prompt).toContain("... (truncated)");
|
||||
// The truncated version should be shorter than original
|
||||
expect(prompt.indexOf("- " + "a".repeat(5000))).toBe(-1);
|
||||
});
|
||||
|
||||
it("truncates diff stat when exceeding MERGE_DIFF_STAT_MAX_CHARS", async () => {
|
||||
const { buildMergePrompt } = await import("./merger.js");
|
||||
|
||||
// Create a diff stat that exceeds 3000 characters
|
||||
const longDiffStat = "file.ts | " + " ".repeat(10) + "x".repeat(4000);
|
||||
const prompt = buildMergePrompt({
|
||||
taskId: "FN-001",
|
||||
branch: "fusion/fn-001",
|
||||
commitLog: "- feat: something",
|
||||
diffStat: longDiffStat,
|
||||
hasConflicts: false,
|
||||
});
|
||||
|
||||
// The prompt should contain truncation indicator
|
||||
expect(prompt).toContain("... (truncated)");
|
||||
// The diff stat section should not contain the full long content
|
||||
expect(prompt.indexOf("x".repeat(3000))).toBe(-1);
|
||||
});
|
||||
|
||||
it("preserves short content unchanged (under limits)", async () => {
|
||||
const { buildMergePrompt } = await import("./merger.js");
|
||||
|
||||
const shortCommitLog = "- feat: add login\n- fix: correct typo";
|
||||
const shortDiffStat = "src/login.ts | 5 +++\n1 file changed";
|
||||
const prompt = buildMergePrompt({
|
||||
taskId: "FN-001",
|
||||
branch: "fusion/fn-001",
|
||||
commitLog: shortCommitLog,
|
||||
diffStat: shortDiffStat,
|
||||
hasConflicts: false,
|
||||
});
|
||||
|
||||
// Should not contain truncation markers
|
||||
expect(prompt).not.toContain("... (truncated)");
|
||||
// Should contain original content
|
||||
expect(prompt).toContain(shortCommitLog);
|
||||
expect(prompt).toContain(shortDiffStat);
|
||||
});
|
||||
|
||||
it("truncates commit log but not diff stat when only commit log is over limit", async () => {
|
||||
const { buildMergePrompt } = await import("./merger.js");
|
||||
|
||||
const longCommitLog = "- " + "b".repeat(6000);
|
||||
const shortDiffStat = "1 file changed";
|
||||
const prompt = buildMergePrompt({
|
||||
taskId: "FN-001",
|
||||
branch: "fusion/fn-001",
|
||||
commitLog: longCommitLog,
|
||||
diffStat: shortDiffStat,
|
||||
hasConflicts: false,
|
||||
});
|
||||
|
||||
// Should contain truncation for commit log
|
||||
expect(prompt).toContain("... (truncated)");
|
||||
// Diff stat should be unchanged
|
||||
expect(prompt).toContain(shortDiffStat);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Context Limit Recovery Tests ─────────────────────────────────────
|
||||
|
||||
describe("aiMergeTask — context limit recovery with truncation", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
});
|
||||
|
||||
function setupContextLimitExecSync() {
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
|
||||
if (cmdStr.includes("git log")) return "- feat: something";
|
||||
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
|
||||
if (cmdStr.includes("--stat")) return "1 file changed";
|
||||
if (cmdStr.includes("merge --squash")) return Buffer.from("");
|
||||
if (cmdStr.includes("diff --cached --quiet")) return "1";
|
||||
if (cmdStr.includes("git commit")) return Buffer.from("");
|
||||
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
|
||||
if (cmdStr.includes("worktree remove")) return Buffer.from("");
|
||||
return Buffer.from("");
|
||||
});
|
||||
}
|
||||
|
||||
it("retries with minimal prompt when context limit hit and compaction returns null", async () => {
|
||||
const { compactSessionContext } = await import("./pi.js");
|
||||
const { isContextLimitError } = await import("./context-limit-detector.js");
|
||||
|
||||
// Mock compaction to return null (fresh session)
|
||||
vi.mocked(compactSessionContext).mockResolvedValue(null);
|
||||
vi.mocked(isContextLimitError).mockReturnValue(true);
|
||||
|
||||
// Track prompt calls
|
||||
const promptCalls: string[] = [];
|
||||
let firstCall = true;
|
||||
mockedCreateHaiAgent.mockImplementation(async () => {
|
||||
const session = {
|
||||
prompt: vi.fn().mockImplementation(async (prompt: string) => {
|
||||
promptCalls.push(prompt);
|
||||
if (firstCall) {
|
||||
firstCall = false;
|
||||
throw new Error("context window exceeds limit (2013)");
|
||||
}
|
||||
// Second call succeeds
|
||||
}),
|
||||
dispose: vi.fn(),
|
||||
};
|
||||
return { session } as any;
|
||||
});
|
||||
|
||||
setupContextLimitExecSync();
|
||||
|
||||
const store = createMockStore(
|
||||
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
|
||||
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
|
||||
);
|
||||
|
||||
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
|
||||
|
||||
// Merge should succeed after truncated retry
|
||||
expect(result.merged).toBe(true);
|
||||
|
||||
// Should have made 2 prompt calls
|
||||
expect(promptCalls).toHaveLength(2);
|
||||
|
||||
// First call had original prompt, second call should have simplified prompt
|
||||
expect(promptCalls[0]).toContain("## Branch commits");
|
||||
// Second call uses simplifiedContext=true, so it should NOT contain "## Files changed"
|
||||
expect(promptCalls[1]).not.toContain("## Files changed");
|
||||
// Second call should have the minimal placeholder
|
||||
expect(promptCalls[1]).toContain("(see git log)");
|
||||
|
||||
// Compaction was attempted
|
||||
expect(vi.mocked(compactSessionContext)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws when truncated retry also fails with context limit", async () => {
|
||||
const { compactSessionContext } = await import("./pi.js");
|
||||
const { isContextLimitError } = await import("./context-limit-detector.js");
|
||||
|
||||
// Mock compaction to return null (fresh session)
|
||||
vi.mocked(compactSessionContext).mockResolvedValue(null);
|
||||
vi.mocked(isContextLimitError).mockReturnValue(true);
|
||||
|
||||
// Track prompt calls to verify both original and truncated prompts were tried
|
||||
const promptCalls: string[] = [];
|
||||
|
||||
mockedCreateHaiAgent.mockImplementation(async () => {
|
||||
const session = {
|
||||
prompt: vi.fn().mockImplementation(async (prompt: string) => {
|
||||
promptCalls.push(prompt);
|
||||
// Both calls fail with context limit error
|
||||
throw new Error("context window exceeds limit (2013)");
|
||||
}),
|
||||
dispose: vi.fn(),
|
||||
};
|
||||
return { session } as any;
|
||||
});
|
||||
|
||||
// Setup that simulates both attempts failing (first fails, attempts 2 and 3 also fail)
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
|
||||
if (cmdStr.includes("git log")) return "- feat: something";
|
||||
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
|
||||
if (cmdStr.includes("--stat")) return "1 file changed";
|
||||
// All merge attempts fail
|
||||
if (cmdStr.includes("merge --squash") || cmdStr.includes("merge -X")) {
|
||||
throw new Error("merge conflict");
|
||||
}
|
||||
if (cmdStr.includes("diff --name-only --diff-filter=U")) return "src/file.ts";
|
||||
if (cmdStr.includes("reset --merge")) return Buffer.from("");
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
const store = createMockStore(
|
||||
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
|
||||
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
|
||||
);
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...DEFAULT_SETTINGS,
|
||||
smartConflictResolution: true, // Enable all 3 attempts
|
||||
});
|
||||
|
||||
// Should throw after all attempts exhausted
|
||||
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toThrow("all 3 attempts exhausted");
|
||||
|
||||
// Verify both original and truncated prompts were attempted (2 attempts for attempt 1)
|
||||
// Each merge attempt calls promptWithFallback twice (original + truncated when compaction fails)
|
||||
// With 3 merge attempts, this means we should have at least 6 prompt calls total
|
||||
expect(promptCalls.length).toBeGreaterThan(0);
|
||||
|
||||
// Compaction was attempted
|
||||
expect(vi.mocked(compactSessionContext)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses normal flow when compaction succeeds (regression test)", async () => {
|
||||
const { compactSessionContext } = await import("./pi.js");
|
||||
const { isContextLimitError } = await import("./context-limit-detector.js");
|
||||
|
||||
// Mock compaction to succeed
|
||||
vi.mocked(compactSessionContext).mockResolvedValue({ summary: "compacted", tokensBefore: 10000 });
|
||||
vi.mocked(isContextLimitError).mockReturnValue(true);
|
||||
|
||||
// Track prompt calls
|
||||
const promptCalls: string[] = [];
|
||||
let firstCall = true;
|
||||
mockedCreateHaiAgent.mockImplementation(async () => {
|
||||
const session = {
|
||||
prompt: vi.fn().mockImplementation(async (prompt: string) => {
|
||||
promptCalls.push(prompt);
|
||||
if (firstCall) {
|
||||
firstCall = false;
|
||||
throw new Error("context window exceeds limit (2013)");
|
||||
}
|
||||
// Second call succeeds
|
||||
}),
|
||||
dispose: vi.fn(),
|
||||
};
|
||||
return { session } as any;
|
||||
});
|
||||
|
||||
setupContextLimitExecSync();
|
||||
|
||||
const store = createMockStore(
|
||||
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
|
||||
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
|
||||
);
|
||||
|
||||
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
|
||||
|
||||
// Merge should succeed after compaction retry
|
||||
expect(result.merged).toBe(true);
|
||||
|
||||
// Should have made 2 prompt calls
|
||||
expect(promptCalls).toHaveLength(2);
|
||||
|
||||
// Compaction was attempted and succeeded
|
||||
expect(vi.mocked(compactSessionContext)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not attempt truncation retry for non-context errors", async () => {
|
||||
const { compactSessionContext } = await import("./pi.js");
|
||||
const { isContextLimitError } = await import("./context-limit-detector.js");
|
||||
|
||||
// Non-context error should not trigger recovery path
|
||||
vi.mocked(compactSessionContext).mockResolvedValue(null);
|
||||
vi.mocked(isContextLimitError).mockReturnValue(false);
|
||||
|
||||
// Mock non-context error
|
||||
mockedCreateHaiAgent.mockImplementation(async () => {
|
||||
const session = {
|
||||
prompt: vi.fn().mockRejectedValue(new Error("connection refused")),
|
||||
dispose: vi.fn(),
|
||||
};
|
||||
return { session } as any;
|
||||
});
|
||||
|
||||
// Setup that simulates merge failing
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
|
||||
if (cmdStr.includes("git log")) return "- feat: something";
|
||||
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
|
||||
if (cmdStr.includes("--stat")) return "1 file changed";
|
||||
// All merge attempts fail
|
||||
if (cmdStr.includes("merge --squash") || cmdStr.includes("merge -X")) {
|
||||
throw new Error("merge conflict");
|
||||
}
|
||||
if (cmdStr.includes("diff --name-only --diff-filter=U")) return "src/file.ts";
|
||||
if (cmdStr.includes("reset --merge")) return Buffer.from("");
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
const store = createMockStore(
|
||||
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
|
||||
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
|
||||
);
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...DEFAULT_SETTINGS,
|
||||
smartConflictResolution: true,
|
||||
});
|
||||
|
||||
// Should throw without attempting compaction or truncation
|
||||
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toThrow("all 3 attempts exhausted");
|
||||
|
||||
// Compaction should NOT have been called for non-context errors
|
||||
expect(vi.mocked(compactSessionContext)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,6 +66,21 @@ const DEPENDENCY_SYNC_TRIGGER_PATTERNS = [
|
||||
const VERIFICATION_COMMAND_MAX_BUFFER = 50 * 1024 * 1024;
|
||||
const VERIFICATION_LOG_MAX_CHARS = 20_000;
|
||||
|
||||
/** Maximum characters for commit log in merge prompt — prevents context overflow on large branches */
|
||||
const MERGE_COMMIT_LOG_MAX_CHARS = 5000;
|
||||
|
||||
/** Maximum characters for diff stat in merge prompt — prevents context overflow on large diffs */
|
||||
const MERGE_DIFF_STAT_MAX_CHARS = 3000;
|
||||
|
||||
/**
|
||||
* Truncate text to maxChars with ellipsis indicator.
|
||||
* Returns original text if under limit.
|
||||
*/
|
||||
function truncateWithEllipsis(text: string, maxChars: number): string {
|
||||
if (text.length <= maxChars) return text;
|
||||
return `${text.slice(0, maxChars)}\n... (truncated)`;
|
||||
}
|
||||
|
||||
function truncateVerificationOutput(output: string): string {
|
||||
if (output.length <= VERIFICATION_LOG_MAX_CHARS) return output;
|
||||
return `... output truncated to last ${VERIFICATION_LOG_MAX_CHARS} characters ...\n${output.slice(-VERIFICATION_LOG_MAX_CHARS)}`;
|
||||
@@ -1788,12 +1803,45 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Compaction unavailable or failed: log and propagate original error.
|
||||
// The outer mergeAttempt loop will clean up and retry the whole attempt
|
||||
// if retries are remaining.
|
||||
mergerLog.error(`${taskId}: session compaction unavailable or failed — cannot continue`);
|
||||
await store.logEntry(taskId, "Session compaction unavailable — cannot continue merge");
|
||||
throw err;
|
||||
// Compaction unavailable or failed (fresh session has nothing to compact):
|
||||
// Retry with a minimal prompt that omits diff stat and uses placeholder commit log.
|
||||
// Use truncatedRetryAttempted flag to prevent infinite loops.
|
||||
let truncatedRetryAttempted = false;
|
||||
mergerLog.warn(`${taskId}: compaction unavailable — retrying with minimal merge prompt`);
|
||||
await store.logEntry(taskId, "Context limit reached — retrying with reduced prompt");
|
||||
|
||||
// Build minimal prompt: omit diff stat, use placeholder for commit log
|
||||
const truncatedPrompt = buildMergePrompt({
|
||||
taskId,
|
||||
branch,
|
||||
commitLog: "(see git log)", // Minimal placeholder instead of full commit log
|
||||
diffStat: "", // Omit diff stat entirely
|
||||
hasConflicts,
|
||||
simplifiedContext: true, // Also skip detailed context
|
||||
testCommand,
|
||||
buildCommand,
|
||||
});
|
||||
|
||||
try {
|
||||
await withRateLimitRetry(async () => {
|
||||
await promptWithFallback(session, truncatedPrompt);
|
||||
checkSessionError(session);
|
||||
truncatedRetryAttempted = true;
|
||||
}, {
|
||||
onRetry: (attempt, delayMs, error) => {
|
||||
const delaySec = Math.round(delayMs / 1000);
|
||||
mergerLog.warn(`⏳ ${taskId} rate limited during truncated retry — retry ${attempt} in ${delaySec}s: ${error.message}`);
|
||||
},
|
||||
});
|
||||
} catch (retryErr: unknown) {
|
||||
// Truncated retry also failed: propagate original error
|
||||
const retryErrorMessage = retryErr instanceof Error ? retryErr.message : String(retryErr);
|
||||
if (isContextLimitError(retryErrorMessage) && truncatedRetryAttempted) {
|
||||
mergerLog.error(`${taskId}: truncated retry also hit context limit — propagating original error`);
|
||||
throw err; // Throw original error with original context
|
||||
}
|
||||
throw retryErr; // Non-context error or other failure
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Non-context error (network, rate limit, build failure): propagate immediately.
|
||||
@@ -1859,15 +1907,19 @@ interface MergePromptParams {
|
||||
buildCommand?: string;
|
||||
}
|
||||
|
||||
function buildMergePrompt(params: MergePromptParams): string {
|
||||
export function buildMergePrompt(params: MergePromptParams): string {
|
||||
const { taskId, branch, commitLog, diffStat, hasConflicts, simplifiedContext, testCommand, buildCommand } = params;
|
||||
|
||||
// Apply truncation to prevent context overflow for large branches/diffs
|
||||
const truncatedCommitLog = truncateWithEllipsis(commitLog, MERGE_COMMIT_LOG_MAX_CHARS);
|
||||
const truncatedDiffStat = truncateWithEllipsis(diffStat, MERGE_DIFF_STAT_MAX_CHARS);
|
||||
|
||||
const parts = [
|
||||
`Finalize the merge of branch \`${branch}\` for task ${taskId}.`,
|
||||
"",
|
||||
"## Branch commits",
|
||||
"```",
|
||||
commitLog,
|
||||
truncatedCommitLog,
|
||||
"```",
|
||||
];
|
||||
|
||||
@@ -1876,7 +1928,7 @@ function buildMergePrompt(params: MergePromptParams): string {
|
||||
"",
|
||||
"## Files changed",
|
||||
"```",
|
||||
diffStat,
|
||||
truncatedDiffStat,
|
||||
"```",
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user