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
5 changes: 5 additions & 0 deletions .changeset/undo-plan-mode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Make /undo exit plan mode when it undoes the turn that entered plan mode.
8 changes: 8 additions & 0 deletions packages/agent-core/src/agent/context/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ export class ContextMemory {
this._lastAssistantAt = null;
this.agent.microCompaction.reset();
this.agent.injection.onContextClear();
this.agent.planMode.onContextClear();
this.agent.emitStatusUpdated();
}

Expand All @@ -167,6 +168,7 @@ export class ContextMemory {
this.agent.records.logRecord({ type: 'context.undo', count });

let removedUserCount = 0;
let oldestRemovedUserIndex: number | undefined;
const removedMessages = new Set<ContextMessage>();
let stoppedAtBoundary = false;
for (let i = this._history.length - 1; i >= 0; i--) {
Expand All @@ -189,6 +191,7 @@ export class ContextMemory {

if (isRealUserInput(message)) {
removedUserCount++;
oldestRemovedUserIndex = i;
if (removedUserCount >= count) break;
}
}
Expand Down Expand Up @@ -218,6 +221,10 @@ export class ContextMemory {
},
);
}

if (oldestRemovedUserIndex !== undefined) {
this.agent.planMode.cancelAfterUndoIfNeeded(oldestRemovedUserIndex);
}
}

applyCompaction(input: CompactionInput): CompactionResult {
Expand Down Expand Up @@ -297,6 +304,7 @@ export class ContextMemory {
this.tokenCountCoveredMessageCount = this._history.length;
this.agent.microCompaction.reset();
this.agent.injection.onContextCompacted();
this.agent.planMode.onContextCompacted();
this.agent.emitStatusUpdated();
return result;
}
Expand Down
30 changes: 30 additions & 0 deletions packages/agent-core/src/agent/plan/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
protected _isActive = false;
protected _planId: null | string = null;
protected _planFilePath: PlanFilePath = null;
protected _enteredAtHistoryLength: number | null = null;

constructor(protected readonly agent: Agent) {}

Expand All @@ -30,6 +31,7 @@
this._isActive = true;
this._planId = id;
this._planFilePath = null;
this._enteredAtHistoryLength = null;

let enterRecorded = false;
try {
Expand All @@ -41,13 +43,15 @@
if (createFile) {
await this.writeEmptyPlanFile(planFilePath);
}
this._enteredAtHistoryLength = this.agent.context.history.length;

Check failure on line 46 in packages/agent-core/src/agent/plan/index.ts

View workflow job for this annotation

GitHub Actions / test

[kimi-core] test/tools/plan-mode-hard-block.test.ts > Plan mode permission policy > does not block read-only tools while plan mode is active

TypeError: Cannot read properties of undefined (reading 'history') ❯ PlanMode.enter src/agent/plan/index.ts:46:57 ❯ activePlanAgent test/tools/plan-mode-hard-block.test.ts:29:3 ❯ test/tools/plan-mode-hard-block.test.ts:212:23

Check failure on line 46 in packages/agent-core/src/agent/plan/index.ts

View workflow job for this annotation

GitHub Actions / test

[kimi-core] test/tools/plan-mode-hard-block.test.ts > Plan mode permission policy > blocks mixed plan-file and non-plan-file write accesses

TypeError: Cannot read properties of undefined (reading 'history') ❯ PlanMode.enter src/agent/plan/index.ts:46:57 ❯ activePlanAgent test/tools/plan-mode-hard-block.test.ts:29:3 ❯ test/tools/plan-mode-hard-block.test.ts:191:33

Check failure on line 46 in packages/agent-core/src/agent/plan/index.ts

View workflow job for this annotation

GitHub Actions / test

[kimi-core] test/tools/plan-mode-hard-block.test.ts > Plan mode permission policy > allows multiple writes when every write access targets the active plan file

TypeError: Cannot read properties of undefined (reading 'history') ❯ PlanMode.enter src/agent/plan/index.ts:46:57 ❯ activePlanAgent test/tools/plan-mode-hard-block.test.ts:29:3 ❯ test/tools/plan-mode-hard-block.test.ts:171:33

Check failure on line 46 in packages/agent-core/src/agent/plan/index.ts

View workflow job for this annotation

GitHub Actions / test

[kimi-core] test/tools/plan-mode-hard-block.test.ts > Plan mode permission policy > blocks Write and Edit with no file write access while plan mode is active

TypeError: Cannot read properties of undefined (reading 'history') ❯ PlanMode.enter src/agent/plan/index.ts:46:57 ❯ activePlanAgent test/tools/plan-mode-hard-block.test.ts:29:3 ❯ test/tools/plan-mode-hard-block.test.ts:152:23

Check failure on line 46 in packages/agent-core/src/agent/plan/index.ts

View workflow job for this annotation

GitHub Actions / test

[kimi-core] test/tools/plan-mode-hard-block.test.ts > Plan mode permission policy > blocks file writes when plan mode has no selected plan file path

TypeError: Cannot read properties of undefined (reading 'history') ❯ PlanMode.enter src/agent/plan/index.ts:46:57 ❯ activePlanAgent test/tools/plan-mode-hard-block.test.ts:29:3 ❯ test/tools/plan-mode-hard-block.test.ts:138:33

Check failure on line 46 in packages/agent-core/src/agent/plan/index.ts

View workflow job for this annotation

GitHub Actions / test

[kimi-core] test/tools/plan-mode-hard-block.test.ts > Plan mode permission policy > blocks file edits when plan mode has no selected plan file path

TypeError: Cannot read properties of undefined (reading 'history') ❯ PlanMode.enter src/agent/plan/index.ts:46:57 ❯ activePlanAgent test/tools/plan-mode-hard-block.test.ts:29:3 ❯ test/tools/plan-mode-hard-block.test.ts:123:33

Check failure on line 46 in packages/agent-core/src/agent/plan/index.ts

View workflow job for this annotation

GitHub Actions / test

[kimi-core] test/tools/plan-mode-hard-block.test.ts > Plan mode permission policy > blocks Write and Edit to non-plan files before permission approval

TypeError: Cannot read properties of undefined (reading 'history') ❯ PlanMode.enter src/agent/plan/index.ts:46:57 ❯ activePlanAgent test/tools/plan-mode-hard-block.test.ts:29:3 ❯ test/tools/plan-mode-hard-block.test.ts:103:23

Check failure on line 46 in packages/agent-core/src/agent/plan/index.ts

View workflow job for this annotation

GitHub Actions / test

[kimi-core] test/tools/plan-mode-hard-block.test.ts > Plan mode permission policy > allows Write and Edit to the active plan file

TypeError: Cannot read properties of undefined (reading 'history') ❯ PlanMode.enter src/agent/plan/index.ts:46:57 ❯ activePlanAgent test/tools/plan-mode-hard-block.test.ts:29:3 ❯ test/tools/plan-mode-hard-block.test.ts:84:33
} catch (error) {
if (enterRecorded) {
this.cancel(id);
} else {
this._isActive = false;
this._planId = null;
this._planFilePath = null;
this._enteredAtHistoryLength = null;
}
throw error;
}
Expand All @@ -64,6 +68,7 @@
this._isActive = true;
this._planId = id;
this._planFilePath = this.planFilePathFor(id);
this._enteredAtHistoryLength = this.agent.context.history.length;
}

cancel(id?: string): void {
Expand All @@ -75,9 +80,33 @@
this._isActive = false;
this._planId = null;
this._planFilePath = null;
this._enteredAtHistoryLength = null;
this.agent.emitStatusUpdated();
}

cancelAfterUndoIfNeeded(removedUserIndex: number): void {
if (!this._isActive) return;
if (this.agent.records.restoring !== null) return;
if (this._enteredAtHistoryLength === null) return;
// Cancel only when plan mode was entered after the user prompt being
// undone. Manual `/plan on` before a prompt should survive undoing that prompt.
if (this._enteredAtHistoryLength > removedUserIndex) {
this.cancel(this._planId ?? undefined);
}
}

onContextClear(): void {
if (this._isActive && this._enteredAtHistoryLength !== null) {
this._enteredAtHistoryLength = 0;
}
}

onContextCompacted(): void {
if (this._isActive && this._enteredAtHistoryLength !== null) {
this._enteredAtHistoryLength = 0;
}
}

async clear(): Promise<void> {
if (!this._planFilePath) return;
await this.writeEmptyPlanFile(this._planFilePath);
Expand All @@ -92,6 +121,7 @@
this._isActive = false;
this._planId = null;
this._planFilePath = null;
this._enteredAtHistoryLength = null;
this.agent.emitStatusUpdated();
}

Expand Down
86 changes: 86 additions & 0 deletions packages/agent-core/test/agent/plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -597,6 +597,92 @@

expect(lastUserText(ctx.agent.context.history)).toContain('Plan mode is active');
});

it('cancels plan mode when undo removes the turn that entered planning', async () => {
const enterPlanModeCall: ToolCall = {
type: 'function',
id: 'call_enter_plan_undo',
name: 'EnterPlanMode',
arguments: '{}',
};
const ctx = testAgent({ kaos: createPlanKaos() });
ctx.configure({ tools: ['EnterPlanMode'] });
await ctx.rpc.setPermission({ mode: 'yolo' });

ctx.mockNextResponse({ type: 'text', text: 'I will enter plan mode.' }, enterPlanModeCall);
ctx.mockNextResponse({ type: 'text', text: 'Plan mode is active now.' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Plan first' }] });
await ctx.untilTurnEnd();

ctx.newEvents();
await ctx.rpc.undoHistory({ count: 1 });

expect(ctx.agent.planMode.isActive).toBe(false);
await expect(ctx.rpc.getPlan({})).resolves.toBeNull();
expect(ctx.newEvents()).toContainEqual(
expect.objectContaining({
type: '[wire]',
event: 'context.undo',
args: expect.objectContaining({ count: 1 }),
}),
);
expect(ctx.newEvents()).toContainEqual(

Check failure on line 629 in packages/agent-core/test/agent/plan.test.ts

View workflow job for this annotation

GitHub Actions / test

[kimi-core] test/agent/plan.test.ts > plan mode injection cadence > cancels plan mode when undo removes the turn that entered planning

AssertionError: expected [] to deep equally contain ObjectContaining{…} - Expected: ObjectContaining { "event": "plan_mode.cancel", "type": "[wire]", } + Received: [] ❯ test/agent/plan.test.ts:629:29
expect.objectContaining({
type: '[wire]',
event: 'plan_mode.cancel',
}),
);
});

it('keeps plan mode active when undo stops after the plan-enter anchor', async () => {
const ctx = testAgent({ kaos: createPlanKaos() });
ctx.configure();
await ctx.rpc.enterPlan({});

ctx.agent.context.appendUserMessage([{ type: 'text', text: 'first plan request' }]);
await ctx.agent.injection.inject();
ctx.appendAssistantTurn(1, 'First plan drafted.');

ctx.agent.context.appendUserMessage([{ type: 'text', text: 'second plan request' }]);
await ctx.agent.injection.inject();
ctx.appendAssistantTurn(2, 'Second plan drafted.');

await ctx.rpc.undoHistory({ count: 1 });

expect(ctx.agent.planMode.isActive).toBe(true);
await expect(ctx.rpc.getPlan({})).resolves.toMatchObject({

Check failure on line 653 in packages/agent-core/test/agent/plan.test.ts

View workflow job for this annotation

GitHub Actions / test

[kimi-core] test/agent/plan.test.ts > plan mode injection cadence > keeps plan mode active when undo stops after the plan-enter anchor

AssertionError: promise rejected "Error: FakeKaos.readText not implemented …" instead of resolving ❯ test/agent/plan.test.ts:653:38 Caused by: Caused by: Error: FakeKaos.readText not implemented — override in test ❯ notImplemented test/tools/fixtures/fake-kaos.ts:20:9 ❯ Object.readText test/tools/fixtures/fake-kaos.ts:56:21 ❯ PlanMode.data src/agent/plan/index.ts:140:39 ❯ Object.getPlan src/agent/index.ts:487:36 ❯ Proxy.<anonymous> test/agent/harness/agent.ts:951:42 ❯ test/agent/plan.test.ts:653:26
path: ctx.agent.planMode.planFilePath,
});
});

it('restores plan mode off from an undo followed by plan cancel', () => {
const ctx = testAgent();
ctx.configure();

ctx.dispatch({
type: 'plan_mode.enter',
id: 'restored-plan',
});
ctx.dispatch({
type: 'context.append_message',
message: {
role: 'user',
content: [{ type: 'text', text: 'draft the plan' }],
toolCalls: [],
origin: { kind: 'user' },
},
});
ctx.dispatch({
type: 'context.undo',
count: 1,
});
ctx.dispatch({
type: 'plan_mode.cancel',
id: 'restored-plan',
});

expect(ctx.agent.planMode.isActive).toBe(false);
});
});

function delay(ms: number): Promise<void> {
Expand Down
Loading