This commit is contained in:
A.K.M. Adib
2026-03-04 16:37:12 -05:00
parent 07d2187a76
commit defc28e42d
12 changed files with 249 additions and 149 deletions
@@ -9,6 +9,7 @@ import { ExitPlanModeTool, ExitPlanModeInvocation } from './exit-plan-mode.js';
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
import path from 'node:path';
import type { Config } from '../config/config.js';
import type { GeminiClient } from '../core/client.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { ToolConfirmationOutcome } from './tools.js';
import { ApprovalMode } from '../policy/types.js';
@@ -44,6 +45,8 @@ describe('ExitPlanModeTool', () => {
getTargetDir: vi.fn().mockReturnValue(tempRootDir),
setApprovalMode: vi.fn(),
setApprovedPlanPath: vi.fn(),
getGeminiClient: vi.fn(),
getPlanModeHistoryStartIndex: vi.fn().mockReturnValue(0),
storage: {
getPlansDir: vi.fn().mockReturnValue(mockPlansDir),
} as unknown as Config['storage'],
@@ -240,6 +243,49 @@ Read and follow the plan strictly during implementation.`,
expect(mockConfig.setApprovedPlanPath).toHaveBeenCalledWith(expectedPath);
});
it('should truncate history surgically when clearConversation is true', async () => {
const planRelativePath = createPlanFile('test.md', '# Content');
const invocation = tool.build({ plan_path: planRelativePath });
const mockSetHistory = vi.fn();
const mockHistory = [
{ role: 'user', parts: [{ text: 'Pre-plan prompt' }] },
{ role: 'model', parts: [{ text: 'Pre-plan response' }] },
{ role: 'user', parts: [{ text: 'Enter plan mode' }] }, // Planning start turn (index 2)
{ role: 'model', parts: [{ text: 'Draft 1' }] },
{ role: 'user', parts: [{ text: 'No, change it' }] },
{
role: 'model',
parts: [{ functionCall: { name: 'exit_plan_mode', args: {} } }],
},
];
vi.mocked(mockConfig.getGeminiClient!).mockReturnValue({
getHistory: vi.fn().mockReturnValue(mockHistory),
setHistory: mockSetHistory,
} as unknown as GeminiClient);
vi.mocked(mockConfig.getPlanModeHistoryStartIndex!).mockReturnValue(2);
const confirmDetails = await invocation.shouldConfirmExecute(
new AbortController().signal,
);
if (confirmDetails === false) return;
await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce, {
approved: true,
approvalMode: ApprovalMode.AUTO_EDIT,
clearConversation: true,
});
await invocation.execute(new AbortController().signal);
expect(mockSetHistory).toHaveBeenCalledWith([
mockHistory[0], // pre-plan user
mockHistory[1], // pre-plan model
mockHistory[2], // first planning user message
mockHistory[5], // last model message (exit_plan_mode)
]);
});
it('should return feedback message when plan is rejected with feedback', async () => {
const planRelativePath = createPlanFile('test.md', '# Content');
const invocation = tool.build({ plan_path: planRelativePath });
+28
View File
@@ -214,6 +214,34 @@ export class ExitPlanModeInvocation extends BaseToolInvocation<
this.config.setApprovalMode(newMode);
this.config.setApprovedPlanPath(resolvedPlanPath);
if (payload.clearConversation) {
const geminiClient = this.config.getGeminiClient();
if (geminiClient) {
const history = geminiClient.getHistory();
const startIndex = this.config.getPlanModeHistoryStartIndex();
// Find the first user message at or after the start index.
// This represents the beginning of the current planning phase.
let planningUserIndex = -1;
for (let i = startIndex; i < history.length - 1; i++) {
if (history[i].role === 'user') {
planningUserIndex = i;
break;
}
}
if (planningUserIndex !== -1) {
const lastModelMessage = history[history.length - 1];
// Keep everything before the plan, plus the initial plan request turn.
const newHistory = [
...history.slice(0, planningUserIndex + 1),
lastModelMessage,
];
geminiClient.setHistory(newHistory);
}
}
}
logPlanExecution(this.config, new PlanExecutionEvent(newMode));
const exitMessage = getPlanModeExitMessage(newMode);
+2
View File
@@ -757,6 +757,8 @@ export interface ToolExitPlanModeConfirmationPayload {
approvalMode?: ApprovalMode;
/** If rejected, the user's feedback */
feedback?: string;
/** If the user wants to clear the conversation context upon approval */
clearConversation?: boolean;
}
export type ToolConfirmationPayload =