Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
c3cf05d
refactor(agent-core-v2): rebuild undo as wire-level journal rewind
7Sageer Jul 22, 2026
1eb8e4b
refactor(agent-core-v2): keep undo domain-owned
7Sageer Jul 22, 2026
72a4b0e
refactor: enhance /undo functionality for consistency and safety, inc…
7Sageer Jul 23, 2026
7c1b69e
chore: clean up undo changeset artifacts
7Sageer Jul 23, 2026
9c48e2f
refactor: rebuild rewind consistency
7Sageer Jul 23, 2026
163044c
fix: make conversation undo durable and consistent
7Sageer Jul 23, 2026
87d08d5
fix(agent-core-v2): stabilize undo restoration
7Sageer Jul 23, 2026
e6a239e
fix: keep TUI undo on legacy error contract
7Sageer Jul 24, 2026
3372b7d
refactor(agent-core-v2): drop unused full compaction cancel API
7Sageer Jul 24, 2026
b644648
fix(agent-core-v2): remove injected context on undo
7Sageer Jul 24, 2026
1a763e1
Merge remote-tracking branch 'upstream/main' into refactor-undo
7Sageer Jul 24, 2026
56457c7
chore(agent-core-v2): regenerate wire manifest
7Sageer Jul 24, 2026
25e9bcb
docs(agent-core-dev): rename rewind to undo in layer table
7Sageer Jul 24, 2026
ee5d6fc
fix(agent-core-v2): undo prompt-owned image reminders
7Sageer Jul 24, 2026
ff1d342
refactor: remove transcript undo reconciliation
7Sageer Jul 24, 2026
8cb8331
fix(kap-server): map undo busy errors
7Sageer Jul 24, 2026
65b6891
refactor(agent-core-v2): rename undo participant registry and attribu…
7Sageer Jul 24, 2026
b53419b
Merge remote-tracking branch 'upstream/main' into refactor-undo
7Sageer Jul 27, 2026
4800dbf
Delete .changeset/fix-undo-injections.md
7Sageer Jul 27, 2026
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`, `undo` |
| 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.
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
6 changes: 5 additions & 1 deletion packages/agent-core-v2/docs/state-manifest.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -653,6 +653,7 @@ export interface SessionStateSnapshot {
} | /* InjectionOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ {
readonly kind: 'injection';
readonly variant: string;
readonly ownerPromptId?: string;
} | /* ShellCommandOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ {
readonly kind: 'shell_command';
readonly phase: 'input' | 'output';
Expand Down Expand Up @@ -728,6 +729,7 @@ export interface AgentStateSnapshot {
} | /* InjectionOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ {
readonly kind: 'injection';
readonly variant: string;
readonly ownerPromptId?: string;
} | /* ShellCommandOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ {
readonly kind: 'shell_command';
readonly phase: 'input' | 'output';
Expand Down Expand Up @@ -851,6 +853,7 @@ export interface AgentStateSnapshot {
} | /* InjectionOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ {
readonly kind: 'injection';
readonly variant: string;
readonly ownerPromptId?: string;
} | /* ShellCommandOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ {
readonly kind: 'shell_command';
readonly phase: 'input' | 'output';
Expand Down Expand Up @@ -906,6 +909,7 @@ export interface AgentStateSnapshot {
} | /* InjectionOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ {
readonly kind: 'injection';
readonly variant: string;
readonly ownerPromptId?: string;
} | /* ShellCommandOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ {
readonly kind: 'shell_command';
readonly phase: 'input' | 'output';
Expand Down Expand Up @@ -1002,7 +1006,7 @@ export interface AgentStateSnapshot {
'llmRequester.lastConfigLogSignature': string | undefined;
'llmRequester.mediaDegradedTurns': Set<number>;
'llmRequester.mediaStrippedTurns': Map<number, /* MediaStripSnapshot — packages/agent-core-v2/src/agent/contextProjector/contextProjector.ts */ {
readonly "__@mediaStripSnapshotBrand@2660": undefined;
readonly "__@mediaStripSnapshotBrand@2662": undefined;
}>;
'llmRequester.turnConfigs': Map<number, /* TurnRequestConfig — packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts */ {
readonly resolved: /* ProfileModelContext — packages/agent-core-v2/src/agent/profile/profile.ts */ {
Expand Down
9 changes: 5 additions & 4 deletions packages/agent-core-v2/docs/wire-manifest.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ interface ContextAppendLoopEventPayload {
}

/**
* model: contextMemory · persisted · blobs · cross-reducers: goalForkNotice, task.notificationDelivery
* model: contextMemory · persisted · blobs · cross-reducers: plan, goalForkNotice, task.notificationDelivery, todo
* owner: src/agent/contextMemory/contextOps.ts
*/
interface ContextAppendMessagePayload {
Expand Down Expand Up @@ -140,22 +140,22 @@ interface ContextAppendMessagePayload {
}

/**
* model: contextMemory · persisted · blobs
* model: contextMemory · persisted · blobs · cross-reducers: plan, task.notificationDelivery, todo
* owner: src/agent/contextMemory/contextOps.ts
* shared base: ...contextCompactionBaseShape
*/
type ContextApplyCompactionPayload = { _name: 'context.apply_compaction'; } & ({ summary: string, compactedCount: number, contextSummary?: string } | { contextSummary: string, compactedCount: number, summary?: string } | { summary: ContextMessage, count: number, compactedCount?: number });

/**
* model: contextMemory · persisted · blobs
* model: contextMemory · persisted · blobs · cross-reducers: plan, task.notificationDelivery, todo
* owner: src/agent/contextMemory/contextOps.ts
*/
interface ContextClearPayload {
_name: 'context.clear';
}

/**
* model: contextMemory · persisted · blobs
* model: contextMemory · persisted · blobs · cross-reducers: plan, task.notificationDelivery, todo
* owner: src/agent/contextMemory/contextOps.ts
*/
interface ContextUndoPayload {
Expand Down Expand Up @@ -570,6 +570,7 @@ interface ToolsUpdateStorePayload {
interface TurnCancelPayload {
_name: 'turn.cancel';
turnId?: number;
target?: 'active' | 'queued';
}

/**
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 @@ -227,6 +227,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
42 changes: 28 additions & 14 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,11 @@ import {
createCompactionSummaryMessage,
type ContextCompactionShapeInput,
} from './compactionHandoff';
import {
isPromptOwnedInjection,
isUndoAnchor,
isValidUndoCount,
} from './conversationTime';
import {
foldAppendMessage,
foldLoopEvent,
Expand Down Expand Up @@ -304,10 +314,16 @@ export function computeUndoCut(state: readonly ContextMessage[], count: number):
stoppedAtCompaction = true;
break;
}
if (isRealUserPrompt(message)) {
if (isUndoAnchor(message)) {
remaining--;
removedCount++;
cutIndex = i;
while (
cutIndex > 0 &&
isPromptOwnedInjection(state[cutIndex - 1]!, message)
) {
cutIndex--;
}
}
}
return { cutIndex, removedCount, stoppedAtCompaction };
Expand All @@ -317,7 +333,11 @@ export function isFullyUndoable(cut: UndoCut, count: number): boolean {
return cut.cutIndex >= 0 && cut.removedCount >= count;
}

export type UndoUnavailableReason = 'empty' | 'compaction_boundary' | 'insufficient';
export type UndoUnavailableReason =
| 'empty'
| 'compaction_boundary'
| 'insufficient'
| 'checkpoint_lost';

export type UndoPrecheck =
| { readonly ok: true }
Expand Down Expand Up @@ -349,25 +369,19 @@ export function formatUndoUnavailableMessage(
return 'Nothing to undo: would cross a compaction boundary';
case 'insufficient':
return `Nothing to undo: only ${precheck.undoable} of ${precheck.requested} requested turn(s) available`;
case 'checkpoint_lost':
return 'Nothing to undo: conversation state checkpoints are incomplete';
}
}

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
Loading