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
2 changes: 1 addition & 1 deletion .agents/skills/agent-core-dev/orient.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ So a Session-scoped service is not "L1" — e.g. `session` is Session-scoped but
| L3 | registries & capabilities | `tool`, `toolRegistry`, `permission*`, `flag`, `skill`, `plugin` |
| L4 | agent behaviour | `turn`, `loop`, `prompt`, `profile`, `contextMemory`, `goal`, `plan`, `swarm` |
| L5 | async lifecycle | `background`, `mcp`, `cron`, `agentTool` |
| L6 | coordination | `session`, `agentLifecycle`, `sessionMetadata`, `interaction`, `terminal` |
| L6 | coordination | `session`, `agentLifecycle`, `sessionMetadata`, `interaction`, `terminal`, `rewind` |
| L7 | boundary / edge | `gateway`, `rpc`, `approval`, `question`, `*Legacy` |

## File-header comment convention
Expand Down
5 changes: 5 additions & 0 deletions .changeset/undo-rewind-consistency.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix /undo to restore conversation history, todo lists, plan mode, and task notifications consistently.
25 changes: 15 additions & 10 deletions apps/kimi-code/src/tui/commands/undo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,18 +375,23 @@ function undoLimitFromError(
): (UndoAvailability & { readonly requestedCount: number }) | undefined {
if (!isKimiError(error)) return undefined;
const details = error.details;
if (details?.['reason'] !== 'undo_limit') return undefined;
const requestedCount = details['requestedCount'];
const maxCount = details['undoableCount'];
const stoppedAtCompaction = details['stoppedAtCompaction'];
if (
typeof requestedCount !== 'number' ||
typeof maxCount !== 'number' ||
typeof stoppedAtCompaction !== 'boolean'
) {
const reason = details?.['reason'];
const requestedCount = details?.['requestedCount'];
const maxCount = details?.['undoableCount'];
if (typeof requestedCount !== 'number' || typeof maxCount !== 'number') {
return undefined;
}

if (reason === 'undo_limit') {
const stoppedAtCompaction = details?.['stoppedAtCompaction'];
if (typeof stoppedAtCompaction !== 'boolean') return undefined;
return { requestedCount, maxCount, stoppedAtCompaction };
}

if (reason !== 'empty' && reason !== 'compaction_boundary' && reason !== 'insufficient') {
return undefined;
}
return { requestedCount, maxCount, stoppedAtCompaction };
return { requestedCount, maxCount, stoppedAtCompaction: reason === 'compaction_boundary' };
}

function isUndoAnchorEntry(entry: TranscriptEntry): boolean {
Expand Down
57 changes: 56 additions & 1 deletion apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,13 @@ import {
resetCapabilitiesCache,
setCapabilities,
} from '@moonshot-ai/pi-tui';
import type { ApprovalRequest, ApprovalResponse, Event } from '@moonshot-ai/kimi-code-sdk';
import {
ErrorCodes,
KimiError,
type ApprovalRequest,
type ApprovalResponse,
type Event,
} from '@moonshot-ai/kimi-code-sdk';
import { afterEach, describe, expect, it, vi } from 'vitest';

import { ApprovalPanelComponent } from '#/tui/components/dialogs/approval-panel';
Expand Down Expand Up @@ -1206,6 +1212,55 @@ command = "vim"
expect(transcript).toContain('hello');
});

it.each([
{
backend: 'v1',
details: {
reason: 'undo_limit',
requestedCount: 1,
undoableCount: 0,
stoppedAtCompaction: true,
},
},
{
backend: 'v2',
details: {
reason: 'compaction_boundary',
requestedCount: 1,
undoableCount: 0,
},
},
])('shows the undo limit from a $backend RPC error', async ({ details }) => {
const session = makeSession({
undoHistory: vi.fn(async () => {
throw new KimiError(ErrorCodes.REQUEST_INVALID, 'Undo unavailable', { details });
}),
});
const { driver } = await makeDriver(session);

driver.handleUserInput('hello');
driver.state.appState.streamingPhase = 'idle';
driver.handleUserInput('/undo 1');

await vi.waitFor(() => {
expect(stripSgr(renderTranscript(driver))).toContain(
'Cannot undo 1 prompt; only 0 prompts can be undone in the active context after the last compaction.',
);
});
expect(stripSgr(renderTranscript(driver))).not.toContain('Error: Failed to undo');
expect(driver.state.transcriptEntries).toEqual([
expect.objectContaining({
kind: 'user',
content: 'hello',
}),
expect.objectContaining({
kind: 'status',
content:
'Cannot undo 1 prompt; only 0 prompts can be undone in the active context after the last compaction.',
}),
]);
});

it('does not duplicate welcome after undoing the only turn', async () => {
const { driver } = await makeDriver();

Expand Down
10 changes: 5 additions & 5 deletions apps/kimi-web/src/components/chat/ChatPane.vue
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ function onQueueDragEnd(): void {
}

// Id of the most recent user turn — the only one offered an "edit & resend"
// affordance (undo only rewinds the latest exchange).
// affordance (undo only removes the latest exchange).
const lastUserTurnId = computed<string | null>(() => {
for (let i = props.turns.length - 1; i >= 0; i--) {
if (props.turns[i]!.role === 'user') return props.turns[i]!.id;
Expand Down Expand Up @@ -314,10 +314,10 @@ function compactionDividerLabel(turn: ChatTurn): string {
// Per-turn copy button state (keyed by turn id)
const copiedTurn = ref<string | null>(null);

// Undo in-flight guard (keyed by turn id) — set while the server rewinds the
// Undo in-flight guard (keyed by turn id) — set while the server undoes the
// turn so a second undo can't fire until the first one settles.
const undoingTurnId = ref<string | null>(null);
// Fallback that releases the undoing state if the server rewind never removes
// Fallback that releases the undoing state if the server undo never removes
// the turn (e.g. the undo failed). Without it the guard in confirmEditMessage
// would block any further undo.
let undoFallbackTimer: ReturnType<typeof setTimeout> | null = null;
Expand All @@ -339,15 +339,15 @@ function confirmEditMessage(turn: ChatTurn): void {
if (undoingTurnId.value !== null) return;
undoingTurnId.value = turn.id;
emit('editMessage', { text: turn.text, attachments: turn.attachments });
// Fallback: if the server rewind never removes the turn (e.g. it failed),
// Fallback: if the server undo never removes the turn (e.g. it failed),
// release the guard so the user can retry.
undoFallbackTimer = setTimeout(() => {
undoFallbackTimer = null;
undoingTurnId.value = null;
}, UNDO_FALLBACK_MS);
}

// Release the undoing guard once the server rewind has actually removed the turn
// Release the undoing guard once the server undo has actually removed the turn
// from the list (post-render, so the element is already gone).
watch(
() => props.turns,
Expand Down
6 changes: 3 additions & 3 deletions apps/kimi-web/src/components/chat/ConversationPane.vue
Original file line number Diff line number Diff line change
Expand Up @@ -828,7 +828,7 @@ watch(scrollKey, async (next, prev) => {
}
await nextTick();
if (following.value || hasUserActionFollowLock()) {
// A rewind (undo / compaction) shortens the transcript — glide to the new
// An undo or compaction shortens the transcript — glide to the new
// bottom smoothly; growth (new turns / streaming) snaps instantly so the
// follow keeps up with the tail.
scrollToBottom(next.length < prev.length);
Expand Down Expand Up @@ -928,9 +928,9 @@ function handleComposerSubmit(payload: { text: string; attachments: PromptAttach
emit('submit', payload);
}

// Undo ("edit & resend") rewinds the transcript asynchronously — the server
// Undo ("edit & resend") shortens the transcript asynchronously — the server
// round-trip in App.vue's handleEditMessage truncates the turns after this emit
// returns. Scrolling here would target the pre-rewind bottom and fight the
// returns. Scrolling here would target the pre-undo bottom and fight the
// bubble-exit animation, so we only arm the follow state; the scrollKey watcher
// smooth-scrolls once the truncated turns actually land.
function handleEditMessage(payload: {
Expand Down
2 changes: 1 addition & 1 deletion docs/en/reference/slash-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ Some commands are only available in the idle state. Executing these commands whi
| `/fork` | — | Fork a new session from the current one, preserving the full conversation history | No |
| `/title [<text>]` | `/rename` | Without arguments, display the current session title; with an argument, set a new title (max 200 characters) | Yes |
| `/compact [<instruction>]` | — | Compact the current conversation context to free up token usage; an optional custom instruction can hint to the model what to preserve | No |
| `/undo [<count>]` | — | Undo recent prompts from the active context. Without a count, opens a selector; with a count, undoes that many prompts. Prompts before the last compaction cannot be undone | No |
| `/undo [<count>]` | — | Undo recent prompts from the active context. Without a count, opens a selector; with a count, undoes that many prompts. Prompts before the last compaction cannot be undone. Undoing also rolls back the todo list and plan mode state produced by those prompts (code changes are not reverted) | No |
| `/reload` | — | Reload the current session and apply the latest `config.toml` settings (providers, models, etc.) and `tui.toml` UI preferences, without restarting the CLI | No |
| `/reload-tui` | — | Reload only the `tui.toml` UI preferences (theme, editor, notifications, etc.) without rebuilding the session | Yes |
| `/init` | — | Analyze the current codebase and generate `AGENTS.md` | No |
Expand Down
2 changes: 1 addition & 1 deletion docs/zh/reference/slash-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
| `/fork` | — | 基于当前会话 fork 一份新会话,保留完整对话历史 | 否 |
| `/title [<text>]` | `/rename` | 不带参数时显示当前会话标题;带参数时设置为新标题(最长 200 字符) | 是 |
| `/compact [<instruction>]` | — | 压缩当前对话上下文,释放 token 占用;可附带自定义指令,提示模型压缩时保留哪些信息 | 否 |
| `/undo [<count>]` | — | 从当前上下文撤销最近的提示词。不带数量时打开选择器;带数量时撤销对应条数。最后一次上下文压缩之前的提示词不能撤销 | 否 |
| `/undo [<count>]` | — | 从当前上下文撤销最近的提示词。不带数量时打开选择器;带数量时撤销对应条数。最后一次上下文压缩之前的提示词不能撤销。撤销会一并回滚这些提示词产生的 todo 列表和计划模式状态(不回滚代码改动) | 否 |
| `/init` | — | 分析当前代码库并生成 `AGENTS.md` | 否 |
| `/export-md [<path>]` | `/export` | 将当前会话导出为 Markdown 文件 | 否 |
| `/export-debug-zip` | — | 将当前会话导出为调试用 ZIP 压缩包(与 [`kimi export`](./kimi-command.md#kimi-export) 行为一致) | 否 |
Expand Down
4 changes: 4 additions & 0 deletions packages/agent-core-v2/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ Business domains **do not implement persistence themselves** — they depend on

Business code must not `import 'node:fs'`, write SQL, hand-roll append-logs / atomic writes, or hold file handles. Generic Stores are named by **access pattern** (`IAppendLogStore`, `IAtomicDocumentStore`); only domain-unique Stores are named after the domain (`ISessionIndex`). See `.agents/skills/agent-core-dev/persistence.md` for the full layering rules and decision tree.

## Conversation undo

`context.undo` is the only persisted undo fact. `contextMemory/conversationTime.ts` owns the conversation clock (`isUndoAnchor` — the single tick predicate used by `computeUndoCut`, the checkpoint reducers, and the transcript reducer) and the checkpoint protocol. A wire Model whose state must follow conversation undo (todo, plan, task-notification delivery, …) **MUST** be defined with `defineCheckpointedModel` — never hand-roll the push/clear/restore reducers — which also registers it into `CHECKPOINTED_MODELS` for the undo pipeline's pre-cut depth check. World-time state (turn counters, task registries, revision counters) must stay outside checkpointed Models.

## Docs

Per-domain references live in `docs/`.
Expand Down
5 changes: 5 additions & 0 deletions packages/agent-core-v2/scripts/check-domain-layers.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,11 @@ const DOMAIN_LAYER = new Map([
['sessionExport', 6],
['interaction', 6],
['sessionMetadata', 6],
// `undo` owns the undo pipeline (quiesce → context.undo → reconcile): it
// coordinates L4 agent domains (loop / prompt / contextMemory /
// fullCompaction), L5 task delivery, and `sessionMetadata`, so it sits in
// L6 beside the other cross-agent coordinators.
['undo', 6],
['sessionActivity', 6],
['session', 6],
['terminal', 6],
Expand Down
Original file line number Diff line number Diff line change
@@ -1,24 +1,12 @@
/**
* `contextMemory` domain (L4) — `IAgentContextMemoryService` implementation.
*
* Owns the per-agent conversation history in the wire `ContextModel`
* (`ContextMessage[]`): reads through `wire.getModel`, writes through the
* v1 wire Ops (`append` / `appendLoopEvent` / `clear` / `undo` /
* `applyCompaction`).
* As the sole live mutation gateway for the history, it also cascades a
* (non-persisted) `context_size.measured` Op alongside every mutation that
* changes the measured prefix — `clear` resets it, `applyCompaction` adopts
* `tokensAfter`, and `undo` rebases it (to an estimate when the measured
* aggregate is truncated); `append` leaves the measured prefix untouched since
* new messages are the unmeasured tail (see `contextSizeService`).
* Splice-shaped mutations publish `context.spliced` from the live path only
* (replay rebuilds the Model silently and never invokes these methods), so
* existing subscribers observe the same change regardless of which Op was
* persisted. Messages
* are persisted without local ids — the on-disk record matches v1's field set
* and public message ids are derived from the transcript index. Blob
* dehydrate/rehydrate is declared on `ContextModel.blobs`. Bound at
* Agent scope.
* Owns per-agent conversation history through `wire`, maintains measurements
* with `contextSize`, and broadcasts live mutations through `event`. Every
* splice-shaped mutation (`clear` / `applyCompaction` / `undo`) publishes
* `context.spliced` from the live path only — replay rebuilds silently — and
* `undo` additionally rebases the measured token prefix to an estimate when
* the cut truncates it. Bound at Agent scope.
*/

import { Disposable } from '#/_base/di/lifecycle';
Expand Down
24 changes: 11 additions & 13 deletions packages/agent-core-v2/src/agent/contextMemory/contextOps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@
* `popSwarmModeReminder`) so the pop replays from the `swarm_mode.exit` record
* itself, exactly like v1's restore-time `popMatchedMessage`.
*
* `context.undo` counts conversation ticks with the single `isUndoAnchor`
* predicate (`./conversationTime`) — the same definition the checkpoint
* protocol pushes with, so anchor counting and checkpoint pushing can never
* drift apart.
*
* Blob handling is declared as a `ModelBlobCodec` on `ContextModel.blobs`:
* - `dehydrate(record, transform)`: at dispatch time, traverses message content
* in `context.append_message` and `context.append_loop_event` records,
Expand All @@ -43,6 +48,7 @@ import {
createCompactionSummaryMessage,
type ContextCompactionShapeInput,
} from './compactionHandoff';
import { isUndoAnchor, isValidUndoCount } from './conversationTime';
import {
foldAppendMessage,
foldLoopEvent,
Expand Down Expand Up @@ -304,7 +310,7 @@ export function computeUndoCut(state: readonly ContextMessage[], count: number):
stoppedAtCompaction = true;
break;
}
if (isRealUserPrompt(message)) {
if (isUndoAnchor(message)) {
remaining--;
removedCount++;
cutIndex = i;
Expand Down Expand Up @@ -353,21 +359,13 @@ export function formatUndoUnavailableMessage(
}

export const contextUndo = ContextModel.defineOp('context.undo', {
schema: z.object({ count: z.number() }),
schema: z.object({
count: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
}),
apply: (state, p) => {
if (p.count <= 0 || state.length === 0) return state;
if (!isValidUndoCount(p.count) || state.length === 0) return state;
const cut = computeUndoCut(state, p.count);
if (!isFullyUndoable(cut, p.count)) return state;
return resetFold(state.slice(0, cut.cutIndex)) as ContextMessage[];
},
});

function isRealUserPrompt(message: ContextMessage): boolean {
if (message.role !== 'user') return false;
const origin = message.origin;
if (origin === undefined || origin.kind === 'user') return true;
return (
(origin.kind === 'skill_activation' || origin.kind === 'plugin_command') &&
origin.trigger === 'user-slash'
);
}
Loading